From 8ce5bc174b29215ca3eefa61b9cf5476cbb2eb3c Mon Sep 17 00:00:00 2001 From: Timmy Release Engineering Date: Sat, 31 Oct 2026 12:05:39 +0000 Subject: [PATCH] fix(ingress): refuse hostile bytes before the disabled-model shortcut (#16) The release builder runs its browser gates with TIMMY_VISION_ENABLED=false, and the 503 "AI analysis is disabled" branch ran before the request budget, body limits, and byte-level ingress guard. Under builder conditions every hostile upload answered 503, so the refusal boundary was never exercised and the ingress-capture gate this same PR added failed: AssertionError: the server must refuse non-image bytes, saw 503 Refusals now run in cost order regardless of model availability: budget, body, bytes, then the disabled switch. Verified with vision disabled: hostile bytes -> 400 not_an_image, a valid synthetic photo -> 503 with the existing copy. npm test 277 pass; ingress-capture and the other browser gates green under the builder's env. --- server.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server.mjs b/server.mjs index 99387e4..9c95cc4 100644 --- a/server.mjs +++ b/server.mjs @@ -76,8 +76,9 @@ http.createServer(async(req,res)=>{ return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy}); } if(appPath==='/api/analyze'&&req.method==='POST'){ - if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'}); - // Cheap refusals first, in cost order: the request budget, then the body, then the image itself. + // Refusals first, in cost order: the request budget, then the body, then the image bytes. None of + // these depend on the model, so hostile bytes are refused as hostile even when analysis is off — + // otherwise the disabled switch hides the ingress boundary behind a 503 and the refusal is untested. const budget=analyzeLimiter.take(clientBucket(req)); if(!budget.allowed){ res.setHeader('retry-after',String(Math.max(1,Math.ceil(budget.retryAfterMs/1000)))); @@ -94,6 +95,8 @@ http.createServer(async(req,res)=>{ } try{ingressGuard(payload)} catch(error){return sendIngressError(res,error)} + // Bytes are known to be a real photo at this point, so the disabled switch is the honest answer. + if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'}); try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))} catch(error){const failure=visionFailureResponse(error);return sendJson(res,failure.status,failure.body)} }