From c830caa73b0a3f075e95b7e46b0236bfb12ea657 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 28 Sep 2026 23:53:01 +0800 Subject: [PATCH] fix(hosting): bound deploy entries like preview before any source work hosting.deploy accepted an unbounded entry list and traversed, authorized and materialized every source before the capacity check, while preview was already capped at 128. Share the bound so a deployment never exceeds what could have been previewed, keeping the published v1 wire schema unchanged. Co-authored-by: Cursor --- src/msg/extensions/hosting.py | 6 ++++- tests/test_hosting_same_origin.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/msg/extensions/hosting.py b/src/msg/extensions/hosting.py index 6810ffa3..380ddee4 100644 --- a/src/msg/extensions/hosting.py +++ b/src/msg/extensions/hosting.py @@ -15,6 +15,8 @@ from msg.plugins.common import check_access,create_resource,revise_resource,resolve,output_for,assert_generation,new_id from msg.plugins.schemas import obj,STRING,IDENTIFIER,REF +MAX_HOSTING_ENTRIES=128 + def path_name(value): require(isinstance(value,str) and 0bounded

'),'media_type':'text/html'}, + key=key,subject=user) + assert site.status==source.status=='ok' + site_id=site.resources[0].id + arguments={'id':site_id,'entries':[{'path':f'{i}.html','source':wire(source.resources[0])} + for i in range(129)]} + spec=app.registry.operation('hosting.deploy',1) + assert 'maxItems' not in app.registry.schema(spec.input_schema)['properties']['entries'] + app.registry.validate(spec.input_schema,arguments) + async with app.metadata.transaction(write=False) as tx: + before=tuple(tx.one(f'SELECT COUNT(*) FROM {table}')[0] + for table in ('resources','revisions','settings')) + async def no_materialization(*args,**kwargs): + pytest.fail('oversized deploy touched the content store') + with monkeypatch.context() as patch: + patch.setattr(app.contents,'read_bytes',no_materialization) + patch.setattr(app.contents,'put_bytes',no_materialization) + denied=await call(app,'hosting.deploy',arguments,key=key,subject=user, + expected=((site_id,site.data['generation']),)) + assert denied.status=='error' and denied.error.code=='too_many_hosting_entries',wire(denied) + async with app.metadata.transaction(write=False) as tx: + assert tuple(tx.one(f'SELECT COUNT(*) FROM {table}')[0] + for table in ('resources','revisions','settings'))==before + assert (await tx.resource(site_id)).revision is None + allowed=await call(app,'hosting.deploy',{**arguments,'entries':arguments['entries'][:128]}, + key=key,subject=user,expected=((site_id,site.data['generation']),)) + assert allowed.status=='ok' and allowed.data['files']==128,wire(allowed) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app)), + base_url='http://testserver') as http: + last=await http.get('/@web-bounded/web/127.html') + assert last.status_code==200 and last.content==b'

bounded

' + isolated(last) + assert (await http.get('/@web-bounded/web/128.html')).status_code==404 + + @pytest.mark.asyncio async def test_preview_history_and_atomic_activation_share_same_csp(installed): app, _ = installed