Files
timmy-talking-turd/server.mjs
T
Timmy Release Engineering 8ce5bc174b
Quality gates / quality (pull_request) Canceled after 0s
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.
2026-10-31 12:06:09 +00:00

127 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import http from 'node:http';
import { isIP } from 'node:net';
import { createHash, randomBytes } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { analyzePhoto, visionFailureResponse } from './src/vision-service.js';
import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js';
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js';
import { readJsonBody } from './src/http-body.js';
import { inspectImageBytes, IngressError } from './src/image-ingress.js';
import { createRateLimiter } from './src/rate-limit.js';
const root=fileURLToPath(new URL('.',import.meta.url));
const port=Number(process.env.PORT||4173);
const host=process.env.HOST||'0.0.0.0';
if(!isIP(host))throw new Error('HOST must be an IPv4 or IPv6 address.');
function resolveBasePath(value) {
const raw=String(value||'');
if(!raw||raw==='/')return '';
if(raw.length>128||!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*\/?$/.test(raw))throw new Error('TIMMY_BASE_PATH must be a canonical absolute URL path using plain unreserved characters.');
const normalized=raw.endsWith('/')?raw.slice(0,-1):raw;
if(normalized.split('/').some(segment=>segment==='.'||segment==='..'))throw new Error('TIMMY_BASE_PATH must not contain traversal segments.');
return normalized;
}
const basePath=resolveBasePath(process.env.TIMMY_BASE_PATH);
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.webmanifest':'application/manifest+json','.svg':'image/svg+xml'};
const visionConfig=resolveVisionConfig(process.env);
const agentConfig=resolveHermesAgentConfig(process.env);
const agentService=createHermesAgentService({config:agentConfig});
const release=/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(process.env.TIMMY_RELEASE_TAG||'')?process.env.TIMMY_RELEASE_TAG:'development';
const commit=/^[0-9a-f]{12,40}$/.test(process.env.TIMMY_RELEASE_COMMIT||'')?process.env.TIMMY_RELEASE_COMMIT:'000000000000';
const publicBasePath=basePath||'/';
const appRoot=basePath?`${basePath}/`:'/';
const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${commit.slice(0,12)}`:'';
function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));}
function escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,character=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[character]));}
// Photo ingress (issue #16). Every limit here is decided from the request itself and fails before
// any provider call: a per-caller request budget, a deadline for a body that never arrives, a byte
// ceiling, and a container/pixel inspection of the decoded image. The bucket key is a salted hash
// that lives only in this process's memory, so no address and no image byte is ever logged.
const bodyTimeoutMs=Number(process.env.TIMMY_BODY_TIMEOUT_MS||15_000);
const analyzeLimiter=createRateLimiter({limit:Number(process.env.TIMMY_ANALYZE_RATE_LIMIT||12),windowMs:Number(process.env.TIMMY_ANALYZE_RATE_WINDOW_MS||60_000)});
const bucketSalt=randomBytes(16).toString('hex');
function clientBucket(req){return createHash('sha256').update(`${bucketSalt}:${req.socket.remoteAddress||'unknown'}`).digest('hex').slice(0,24);}
function readJson(req,maxBytes=6*1024*1024){return readJsonBody(req,{maxBytes,timeoutMs:bodyTimeoutMs});}
function ingressGuard(payload){
const raw=String(payload?.imageDataUrl||'');
const match=/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/.exec(raw);
// A payload that is not shaped like a data URL is left to the semantic validator, so the user still
// gets the specific "upload a JPEG, PNG, or WebP photo" copy rather than a second opinion here.
if(!match)return null;
return inspectImageBytes(Buffer.from(match[2],'base64'),{declaredMime:match[1]});
}
function sendIngressError(res,error){
if(error instanceof IngressError)return sendJson(res,400,{error:error.message,code:error.code});
return sendJson(res,error.status||400,{error:error.message,code:error.code});
}
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')}
function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=${appRoot}; Max-Age=86400${secure}`}
function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})}
http.createServer(async(req,res)=>{
try{
const url=new URL(req.url,'http://localhost');
if(basePath&&url.pathname!==basePath&&!url.pathname.startsWith(`${basePath}/`)){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});return res.end('Not found')}
if(basePath&&url.pathname===basePath){res.writeHead(308,{location:appRoot});return res.end()}
const appPath=basePath?(url.pathname.slice(basePath.length)||'/'):url.pathname;
if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled});
if(appPath==='/api/vision-status'&&req.method==='GET'){
const provider=await probeVisionProvider(visionConfig);
const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.';
return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy});
}
if(appPath==='/api/analyze'&&req.method==='POST'){
// 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))));
return sendJson(res,429,{error:'Too many photos from this device. Wait a moment, then try again — your journal and manual logging still work.'});
}
let payload;
try{payload=await readJson(req)}
catch(error){
const sent=sendIngressError(res,error);
// The refusal is written first, then the half-read socket is dropped so a stalled body cannot
// hold a connection or its partial buffer open.
if(error?.code==='body_timeout'||error?.code==='too_large')req.destroy?.();
return sent;
}
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)}
}
if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
if(appPath==='/api/agent/unlock'&&req.method==='POST'){
try{rejectCrossSite(req);const payload=await readJson(req,4096);const result=await agentService.unlock({origin:requestOrigin(req),accessCode:String(payload?.accessCode||'')});res.setHeader('set-cookie',agentCookie(result.cookieToken));return sendJson(res,200,result.public)}catch(error){return sendAgentError(res,error)}
}
if(appPath==='/api/agent/chat'&&req.method==='POST'){
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)}
}
if(appPath.startsWith('/api/'))return sendJson(res,404,{error:'Not found'});
if(req.method!=='GET'&&req.method!=='HEAD'){res.writeHead(405,{'allow':'GET, HEAD'});return res.end()}
const pathname=decodeURIComponent(appPath);
let path=normalize(join(root,pathname==='/'?'index.html':pathname));
if(!path.startsWith(root))throw new Error('bad path');
const info=await stat(path);if(info.isDirectory())path=join(path,'index.html');
let body=await readFile(path);
if(appPath==='/'||appPath==='/index.html'){
const configMeta=`<meta name="timmy-base-path" content="${escapeHtmlAttribute(publicBasePath)}">\n <meta name="timmy-staging-label" content="${escapeHtmlAttribute(stagingLabel)}">`;
body=body.toString('utf8').replace(/(["'])\//g,`$1${appRoot}`).replace('<head>',`<head>\n <base href="${appRoot}">\n ${configMeta}`);
}
if(appPath==='/manifest.webmanifest'){
const manifest=JSON.parse(body.toString('utf8'));manifest.start_url=appRoot;manifest.scope=appRoot;manifest.icons=manifest.icons.map(icon=>({...icon,src:`${appRoot}${icon.src.replace(/^\//,'')}`}));body=JSON.stringify(manifest);
}
res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body);
}catch(error){if(error instanceof AgentGatewayError)return sendAgentError(res,error);res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found')}
}).listen(port,host,()=>console.log(`Timmy is listening on http://${host}:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`));