Some checks failed
Quality gates / quality (pull_request) Failing after 2m39s
Closes the PR 63 hostile-review blockers: 1. Immutable pinned Python/Pillow runtime (TIMMY_PYTHON absolute, --verify-pin), deployment/runtime re-encode smoke gate in build_release + deploy_staging. 2. Header-only width/height/total-pixel/bomb rejection before full decode; proves 6000x6000 and 12000x12000 stay resource bounded (RSS + address-space caps). 3. Fail-fast decoder concurrency ceiling; tests count actual spawned children. 4. Rate-limiter key cardinality hard-bounded under 20k+ unexpired identities, trusted-loopback-proxy identity, no spoofable forwarded headers, fixed-window boundary burst smoothed by two-window sliding count. 5. build_release explicitly syntax/gates every new JS module + Python re-encoder + production-runtime smoke; CI runs reencode-image test and the runtime pin smoke. 6. Robust polyglot contract via canonical container parsing; rejects uppercase/mixed script, appended HTML, ZIP local/EOCD and archive tails, data after canonical JPEG/PNG/WebP end; no naive compressed-byte scans (false-positive controls pass). 7. Canonical base64 data-URL grammar with byte-exact round trip; rejects missing/excess padding, whitespace/CRLF, malformed and noncanonical encodings; exact MIME policy. 8. Missing Pillow / runtime-unavailable maps to sanitized 503 + manual fallback. 9. Inbound body-read timeout and stop-on-oversize; preserves sanitized 413, base path, provider suppression, and temp cleanup; socket torn down on rejection. Audited prior partial edits: reused the sound source modules, re-wired new tests into the unit/syntax gates, fixed a non-canonical readJson that destroyed the socket before delivering 413/408, and hardened test flakiness (port reuse, EPIPE, startup races).
184 lines
12 KiB
JavaScript
184 lines
12 KiB
JavaScript
import http from 'node:http';
|
||
import { isIP } from 'node:net';
|
||
import { readFile, stat } from 'node:fs/promises';
|
||
import { extname, join, normalize } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { analyzePhoto } from './src/vision-service.js';
|
||
import { createRateLimiter } from './src/rate-limiter.js';
|
||
import { resolveClientIdentity, resolveTrustedProxies } from './src/client-identity.js';
|
||
import { IngressUnavailableError } from './src/image-ingress.js';
|
||
import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js';
|
||
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js';
|
||
|
||
const analyzeRateLimiter=createRateLimiter();
|
||
// Explicit rate-limit identity policy. Behind a reverse proxy the socket peer is
|
||
// the proxy itself, so a forwarded client address is only honoured when the peer
|
||
// is in this configured allowlist. Otherwise the peer address is used, and a
|
||
// trusted proxy that forwards nothing usable degrades to a truthful shared quota.
|
||
const trustedProxies=resolveTrustedProxies(process.env.TIMMY_TRUSTED_PROXIES);
|
||
|
||
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=>({'&':'&','<':'<','>':'>','"':'"'}[character]));}
|
||
const BODY_READ_TIMEOUT_MS = Math.max(500, Number.parseInt(process.env.TIMMY_BODY_READ_TIMEOUT_MS || '10000', 10) || 10000);
|
||
const MAX_BODY_BYTES = 6 * 1024 * 1024;
|
||
|
||
/**
|
||
* Read a JSON request body with a hard inbound timeout and a stop-on-oversize
|
||
* guard. The connection is actively destroyed the moment either limit trips, so
|
||
* a hostile client cannot hold a request open by dribbling bytes or force the
|
||
* server to drain an enormous payload before rejecting it.
|
||
*/
|
||
function readJson(req, res, maxBytes = MAX_BODY_BYTES) {
|
||
return new Promise((resolve, reject) => {
|
||
let size = 0;
|
||
let tooLarge = false;
|
||
let settled = false;
|
||
const chunks = [];
|
||
let timer = null;
|
||
|
||
const fail = (error) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
if (timer) clearTimeout(timer);
|
||
req.off('data', onData);
|
||
req.off('end', onEnd);
|
||
req.off('error', onError);
|
||
reject(error);
|
||
};
|
||
|
||
const onData = (chunk) => {
|
||
if (settled || tooLarge) return;
|
||
size += chunk.length;
|
||
if (size > maxBytes) {
|
||
tooLarge = true;
|
||
// Reject immediately so the transport can answer a sanitized 413 without
|
||
// waiting for the client to finish streaming an enormous payload. The
|
||
// socket is torn down by the caller after the response is written.
|
||
return fail(new AgentGatewayError(413, 'Request is too large. Use a smaller photo or continue manually.'));
|
||
}
|
||
chunks.push(chunk);
|
||
};
|
||
const onEnd = () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
if (timer) clearTimeout(timer);
|
||
if (tooLarge) return;
|
||
try {
|
||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||
} catch {
|
||
reject(new AgentGatewayError(400, 'Invalid JSON request.'));
|
||
}
|
||
};
|
||
const onError = (error) => {
|
||
// A broken socket is torn down here; the rejection still propagates.
|
||
try { req.destroy(); } catch { /* already gone */ }
|
||
fail(error);
|
||
};
|
||
|
||
timer = setTimeout(() => {
|
||
// Hard inbound timeout: a slow-dribbling client must not hold the
|
||
// connection open. The caller answers 408 and destroys the socket.
|
||
fail(new AgentGatewayError(408, 'Request timed out while reading the upload.'));
|
||
}, BODY_READ_TIMEOUT_MS);
|
||
|
||
req.on('data', onData);
|
||
req.on('end', onEnd);
|
||
req.on('error', onError);
|
||
});
|
||
}
|
||
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})}
|
||
|
||
// Ingress failures carry their own honest classification: a server-side
|
||
// processing fault must not be reported as corrupt client input.
|
||
function handleAnalyzeError(res,error){
|
||
if(error instanceof IngressUnavailableError||error?.ingressUnavailable===true)return sendJson(res,503,{error:error.message,manualFallback:true});
|
||
const safe=/consent|JPEG|PNG|WebP|empty|too large|supported image|corrupt|malformed|slow down/i.test(error.message);
|
||
return sendJson(res,safe?400:503,{error:error.message});
|
||
}
|
||
// A body-read rejection (oversized or timed out) is answered with a sanitized
|
||
// status and then the socket is torn down so a hostile client cannot keep the
|
||
// request open by dribbling or draining an enormous payload.
|
||
function handleBodyError(res,req,error){
|
||
if(typeof error?.status==='number'){sendJson(res,error.status,{error:error.message});}
|
||
else{sendJson(res,400,{error:'Invalid request.'});}
|
||
try{res.end();req.destroy();}catch{/* already closed */}
|
||
}
|
||
|
||
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;
|
||
// Nested API routes under the base path (e.g. /timmy-staging/api/healthz) are
|
||
// routed to their canonical /api/* handler so the base path is preserved on
|
||
// every operational endpoint, not just the root.
|
||
const apiPath=basePath&&appPath.startsWith('/api/')?appPath:appPath;
|
||
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'){
|
||
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
|
||
const identity=resolveClientIdentity({remoteAddress:req.socket?.remoteAddress,headers:req.headers,trustedProxies});
|
||
const limit=analyzeRateLimiter.take(identity.key);
|
||
if(!limit.allowed){
|
||
res.setHeader('retry-after',Math.ceil(limit.retryAfterMs/1000));
|
||
return sendJson(res,429,{error:'Too many photo analyses. Please slow down and try again later.'});
|
||
}
|
||
try{const payload=await readJson(req,res);try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}catch(error){return handleAnalyzeError(res,error)}}catch(error){return handleBodyError(res,req,error)}
|
||
}
|
||
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'}`));
|