From 0e58ce1d5513bee2c3a6e0522b2d275e36390c61 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 23:29:01 +0000 Subject: [PATCH] feat: complete runtime for production exposure - Add RECEIPT_SIGNING_PUBLIC_KEY_PEM env (plain PEM alternative to B64) - Add resolvePublicPem() fallback chain: B64 env -> plain PEM env - Add verifier_ok field to /health and /debug/env - Add structured JSON request logging (LOG_REQUESTS=1 default) - Add per-IP rate limiting (RATE_LIMIT_ENABLED, opt-in) - Return 503 (not 500) when request schema validator is unavailable - Fix doClean empty-string false-positive (null/undefined check) - Update CONFIGURATION.md with all new env vars (CORS, debug, logging, rate limit) - Expand smoke tests: CORS blocking, plain PEM verify, debug route disable https://claude.ai/code/session_01Jh9WXqejeBVXesWCkpt9an --- docs/CONFIGURATION.md | 38 ++++++++++++++++- server.mjs | 82 +++++++++++++++++++++++++++++++++--- tests/smoke.mjs | 97 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 205 insertions(+), 12 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f239cc5..78f89df 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -26,7 +26,8 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`. |---|---|---| | `RECEIPT_SIGNER_ID` | `runtime` (or `ENS_NAME` when set) | Receipt proof signer identifier. | | `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` | empty | Required for signing receipts. Base64 of PEM private key. | -| `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` | empty | Optional local pubkey for `/verify` signature checks. | +| `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` | empty | Public key for `/verify` (base64-encoded PEM). | +| `RECEIPT_SIGNING_PUBLIC_KEY_PEM` | empty | Public key for `/verify` (plain PEM text). Either this or the B64 variant is sufficient. | | `ENS_NAME` | empty | Optional identity alias fallback. | ## ENS-based verification @@ -45,6 +46,7 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`. | `SCHEMA_FETCH_TIMEOUT_MS` | `15000` | Timeout per schema document fetch. | | `SCHEMA_VALIDATE_BUDGET_MS` | `15000` | Budget for async schema compilation. | | `VERIFY_SCHEMA_CACHED_ONLY` | `1` | If `1`, `/verify?schema=1` only uses warm validators and returns `202` on cold cache. | +| `REQUEST_SCHEMA_VALIDATION` | `0` | If `1`, validate verb request payloads against published request schemas. Returns `503` if schemas are unavailable. | ## Cache controls @@ -71,6 +73,35 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`. | `ENABLE_SSRF_GUARD` | `1` | Enables DNS/IP/local-network SSRF checks. | | `ALLOW_FETCH_HOSTS` | empty | Optional CSV domain allowlist (`example.com,api.example.com`). | +## CORS + +| Variable | Default | Purpose | +|---|---|---| +| `CORS_ALLOW_ORIGINS` | empty | Comma-separated list of allowed origins. Empty = deny browser-origin requests. Use `*` to allow all (not recommended in production). | +| `CORS_ALLOW_HEADERS` | `Content-Type, Authorization` | Allowed request headers. | +| `CORS_ALLOW_METHODS` | `GET,POST,OPTIONS` | Allowed HTTP methods. | + +## Debug routes + +| Variable | Default | Purpose | +|---|---|---| +| `DEBUG_ROUTES_ENABLED` | `0` | If `1`, enables `/debug/*` endpoints. Disabled by default in production. | +| `DEBUG_BEARER_TOKEN` | empty | If set, requires `Authorization: Bearer ` on all debug routes. | + +## Request logging + +| Variable | Default | Purpose | +|---|---|---| +| `LOG_REQUESTS` | `1` | If `1`, emits structured JSON log lines to stdout for every request. | + +## Rate limiting + +| Variable | Default | Purpose | +|---|---|---| +| `RATE_LIMIT_ENABLED` | `0` | If `1`, enables per-IP rate limiting. | +| `RATE_LIMIT_MAX` | `120` | Max requests per window per IP. | +| `RATE_LIMIT_WINDOW_MS` | `60000` | Sliding window duration in milliseconds. | + ## Schema prewarm behavior | Variable | Default | Purpose | @@ -81,8 +112,11 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`. ## Recommended production baseline -- Set explicit signing keys and verify `signer_ok=true` on `/health`. +- Set explicit signing keys and verify `signer_ok=true` and `verifier_ok=true` on `/health`. - Keep `VERIFY_SCHEMA_CACHED_ONLY=1` for edge stability. +- Set `CORS_ALLOW_ORIGINS` to specific origins (never `*` in production). +- Set `DEBUG_ROUTES_ENABLED=0` (default) or protect with `DEBUG_BEARER_TOKEN`. +- Set `RATE_LIMIT_ENABLED=1` with appropriate limits for your traffic profile. - Restrict egress using both network policy and `ALLOW_FETCH_HOSTS` where possible. - Tune `FETCH_MAX_BYTES` and timeout budgets based on expected payload sizes. - Poll `/debug/validators` after deploy and prewarm critical verbs. diff --git a/server.mjs b/server.mjs index b1b1939..c630fd2 100644 --- a/server.mjs +++ b/server.mjs @@ -45,6 +45,68 @@ app.use((req, res, next) => { const PORT = Number(process.env.PORT || 8080); +// ---- structured request logging +const LOG_REQUESTS = String(process.env.LOG_REQUESTS || "1") === "1"; + +app.use((req, res, next) => { + if (!LOG_REQUESTS) return next(); + const start = Date.now(); + const onFinish = () => { + res.removeListener("finish", onFinish); + const duration = Date.now() - start; + const entry = { + ts: new Date().toISOString(), + method: req.method, + path: req.path, + status: res.statusCode, + duration_ms: duration, + ip: req.ip || req.socket?.remoteAddress, + ua: req.headers["user-agent"] || null, + }; + process.stdout.write(JSON.stringify(entry) + "\n"); + }; + res.on("finish", onFinish); + next(); +}); + +// ---- basic rate limiting (in-memory, per-IP) +const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS || 60000); +const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX || 120); +const RATE_LIMIT_ENABLED = String(process.env.RATE_LIMIT_ENABLED || "0") === "1"; + +const rateBuckets = new Map(); + +function pruneRateBuckets() { + const now = Date.now(); + for (const [key, bucket] of rateBuckets) { + if (now - bucket.windowStart > RATE_LIMIT_WINDOW_MS * 2) rateBuckets.delete(key); + } + if (rateBuckets.size > 10000) rateBuckets.clear(); +} + +app.use((req, res, next) => { + if (!RATE_LIMIT_ENABLED) return next(); + const key = req.ip || req.socket?.remoteAddress || "unknown"; + const now = Date.now(); + + pruneRateBuckets(); + + let bucket = rateBuckets.get(key); + if (!bucket || now - bucket.windowStart > RATE_LIMIT_WINDOW_MS) { + bucket = { windowStart: now, count: 0 }; + rateBuckets.set(key, bucket); + } + bucket.count++; + + res.setHeader("X-RateLimit-Limit", String(RATE_LIMIT_MAX)); + res.setHeader("X-RateLimit-Remaining", String(Math.max(0, RATE_LIMIT_MAX - bucket.count))); + + if (bucket.count > RATE_LIMIT_MAX) { + return res.status(429).json(makeError(429, "Rate limit exceeded", { retry_after_ms: RATE_LIMIT_WINDOW_MS })); + } + next(); +}); + // ---- runtime config const ENABLED_VERBS = (process.env.ENABLED_VERBS || "fetch,describe,format,clean,parse,summarize,convert,explain,analyze,classify") .split(",") @@ -54,6 +116,7 @@ const ENABLED_VERBS = (process.env.ENABLED_VERBS || "fetch,describe,format,clean const SIGNER_ID = process.env.RECEIPT_SIGNER_ID || process.env.ENS_NAME || "runtime"; const PRIV_PEM_B64 = process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 || ""; const PUB_PEM_B64 = process.env.RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 || ""; +const PUB_PEM_RAW = process.env.RECEIPT_SIGNING_PUBLIC_KEY_PEM || ""; // ---- service identity / discovery const SERVICE_NAME = process.env.SERVICE_NAME || "commandlayer-runtime"; @@ -142,6 +205,12 @@ function pemFromB64(b64) { return pem.includes("BEGIN") ? pem : null; } +function resolvePublicPem() { + const fromB64 = pemFromB64(PUB_PEM_B64); + if (fromB64) return fromB64; + return normalizePem(PUB_PEM_RAW); +} + function normalizePem(text) { if (!text) return null; const pem = String(text).replace(/\\n/g, "\n").trim(); @@ -639,7 +708,7 @@ function doFormat(body) { function doClean(body) { const input = body?.input || {}; let content = String(input.content ?? ""); - if (!content) throw new Error("clean.input.content required"); + if (input.content === undefined || input.content === null) throw new Error("clean.input.content required"); const ops = Array.isArray(input.operations) ? input.operations : []; const issues = []; const apply = (op) => { @@ -932,7 +1001,7 @@ async function handleVerb(verb, req, res) { ); } } catch (e) { - return res.status(500).json(makeError(500, `Request schema validator unavailable: ${e?.message || "unknown error"}`)); + return res.status(503).json(makeError(503, `Request schema validator unavailable: ${e?.message || "unknown error"}`, { retryable: true })); } } @@ -1055,6 +1124,7 @@ app.get("/health", (req, res) => { enabled_verbs: ENABLED_VERBS, signer_id: SIGNER_ID, signer_ok: !!pemFromB64(PRIV_PEM_B64), + verifier_ok: !!resolvePublicPem(), time: nowIso(), }) ); @@ -1072,6 +1142,8 @@ app.get("/debug/env", (req, res) => { signer_ok: !!pemFromB64(PRIV_PEM_B64), has_priv_b64: !!PRIV_PEM_B64, has_pub_b64: !!PUB_PEM_B64, + has_pub_pem: !!PUB_PEM_RAW, + verifier_ok: !!resolvePublicPem(), verifier_ens_name: VERIFIER_ENS_NAME || null, ens_pubkey_text_key: ENS_PUBKEY_TEXT_KEY, has_rpc: hasRpc(), @@ -1259,8 +1331,8 @@ app.post("/verify", async (req, res) => { const hashMatches = recomputed === proof.hash_sha256; - let pubPem = pemFromB64(PUB_PEM_B64); - let pubSrc = pubPem ? "env-b64" : null; + let pubPem = resolvePublicPem(); + let pubSrc = pubPem ? "env" : null; if (wantEns) { const ensOut = await fetchEnsPubkeyPem({ refresh }); @@ -1284,7 +1356,7 @@ app.post("/verify", async (req, res) => { } } else { sigOk = false; - sigErr = "no public key available (set RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 or pass ens=1 with ETH_RPC_URL)"; + sigErr = "no public key available (set RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64 or RECEIPT_SIGNING_PUBLIC_KEY_PEM, or pass ens=1 with ETH_RPC_URL)"; } // Schema validation (edge-safe) diff --git a/tests/smoke.mjs b/tests/smoke.mjs index cf5e3d8..90e0c6d 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -47,6 +47,8 @@ try { DEBUG_BEARER_TOKEN: 'secret-token', REQUEST_SCHEMA_VALIDATION: '0', CORS_ALLOW_ORIGINS: 'http://allowed.local', + LOG_REQUESTS: '0', + RATE_LIMIT_ENABLED: '0', }; const server = spawn('node', ['server.mjs'], { @@ -61,14 +63,15 @@ try { try { await waitForHealth(); - // signer readiness + // ---- health: signer + verifier readiness ---- const healthResp = await fetch(`${base}/health`); assert.equal(healthResp.ok, true); const health = await healthResp.json(); assert.equal(health.ok, true); assert.equal(health.signer_ok, true); + assert.equal(health.verifier_ok, true); - // verb execution + // ---- verb execution ---- const verbResp = await fetch(`${base}/describe/v1.0.0`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -82,7 +85,7 @@ try { assert.equal(receipt.status, 'success'); assert.ok(receipt.metadata?.proof?.signature_b64); - // verify pass path + // ---- verify pass path ---- const verifyResp = await fetch(`${base}/verify`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -94,7 +97,7 @@ try { assert.equal(verify.checks.signature_valid, true); assert.equal(verify.checks.hash_matches, true); - // verify fail path (tamper hash) + // ---- verify fail path (tamper hash) ---- const tampered = structuredClone(receipt); tampered.metadata.proof.hash_sha256 = randomBytes(32).toString('hex'); const badVerifyResp = await fetch(`${base}/verify`, { @@ -107,22 +110,106 @@ try { assert.equal(badVerify.ok, false); assert.equal(badVerify.checks.hash_matches, false); - // debug route auth + // ---- debug route: 401 without token ---- const debugNoToken = await fetch(`${base}/debug/env`); assert.equal(debugNoToken.status, 401); + // ---- debug route: 200 with valid token ---- const debugWithToken = await fetch(`${base}/debug/env`, { headers: { authorization: 'Bearer secret-token' }, }); assert.equal(debugWithToken.ok, true); const debug = await debugWithToken.json(); assert.equal(debug.debug_routes_enabled, true); + assert.equal(debug.verifier_ok, true); assert.equal(debug.cors.allow_origins.includes('http://allowed.local'), true); + + // ---- CORS: blocked origin ---- + const corsBlocked = await fetch(`${base}/health`, { + headers: { origin: 'http://evil.example.com' }, + }); + assert.equal(corsBlocked.status, 403); + + // ---- CORS: allowed origin ---- + const corsAllowed = await fetch(`${base}/health`, { + headers: { origin: 'http://allowed.local' }, + }); + assert.equal(corsAllowed.ok, true); + assert.equal(corsAllowed.headers.get('access-control-allow-origin'), 'http://allowed.local'); + + // ---- disabled verb returns 404 ---- + const disabledResp = await fetch(`${base}/nonexistent/v1.0.0`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + assert.equal(disabledResp.status, 404); + } finally { server.kill('SIGTERM'); await sleep(150); if (!server.killed) server.kill('SIGKILL'); } + + // ---- Phase 2: test plain PEM key (not base64) ---- + const plainPemEnv = { + ...process.env, + PORT: String(PORT), + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: b64File(priv), + RECEIPT_SIGNING_PUBLIC_KEY_PEM: readFileSync(pub, 'utf8'), + RECEIPT_SIGNER_ID: 'runtime.test.pem', + DEBUG_ROUTES_ENABLED: '0', + REQUEST_SCHEMA_VALIDATION: '0', + LOG_REQUESTS: '0', + }; + // Ensure the B64 variant is NOT set so we exercise the plain PEM path + delete plainPemEnv.RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64; + + const server2 = spawn('node', ['server.mjs'], { + env: plainPemEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let logs2 = ''; + server2.stdout.on('data', (d) => (logs2 += d.toString())); + server2.stderr.on('data', (d) => (logs2 += d.toString())); + + try { + await waitForHealth(); + + const health2 = await (await fetch(`${base}/health`)).json(); + assert.equal(health2.verifier_ok, true, 'plain PEM pubkey should resolve'); + + // sign + verify with plain PEM key + const verbResp2 = await fetch(`${base}/describe/v1.0.0`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + x402: { entry: 'x402://describeagent.eth/describe/v1.0.0', verb: 'describe', version: '1.0.0' }, + input: { subject: 'CommandLayer', detail_level: 'short' }, + }), + }); + assert.equal(verbResp2.ok, true); + const receipt2 = await verbResp2.json(); + + const verify2 = await (await fetch(`${base}/verify`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(receipt2), + })).json(); + assert.equal(verify2.ok, true, 'verify should pass with plain PEM pubkey'); + assert.equal(verify2.checks.signature_valid, true); + + // debug routes should be disabled (404) + const debugOff = await fetch(`${base}/debug/env`); + assert.equal(debugOff.status, 404); + + } finally { + server2.kill('SIGTERM'); + await sleep(150); + if (!server2.killed) server2.kill('SIGKILL'); + } + } catch (err) { writeFileSync('/tmp/runtime-smoke-failure.log', String(err?.stack || err)); throw err;