diff --git a/adapters/adapter-core/package.json b/adapters/adapter-core/package.json index 0e715fa..3bb90d4 100644 --- a/adapters/adapter-core/package.json +++ b/adapters/adapter-core/package.json @@ -27,6 +27,6 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "tsc -p tsconfig.json && node test/static.test.mjs" + "test": "tsc -p tsconfig.json && node test/static.test.mjs && node test/host.test.mjs" } } diff --git a/adapters/adapter-core/src/server.ts b/adapters/adapter-core/src/server.ts index 90b22a3..81a4295 100644 --- a/adapters/adapter-core/src/server.ts +++ b/adapters/adapter-core/src/server.ts @@ -92,6 +92,16 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { const opByName = new Map(operations.map((o) => [o.name, o])); const eventTypes = knownEventTypes(config.events); const allowedOrigins = new Set(config.allowedOrigins ?? []); + // Host names this app will answer to. Derived from the origins it already + // declares, so an app that configures its own UI origin keeps working. + const allowedHosts = new Set((config.allowedHosts ?? []).map((h) => h.toLowerCase())); + for (const origin of config.allowedOrigins ?? []) { + try { + allowedHosts.add(new URL(origin).host.toLowerCase()); + } catch { + /* not a URL - ignore */ + } + } const audit = config.audit ?? ((e: AuditEntry) => store.appendAudit(e)); const limiter = new RateLimiter({ ...DEFAULT_RATE_LIMITS, ...(config.rateLimits ?? {}) }, () => now().getTime()); @@ -411,6 +421,25 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { return store.grantByToken(token); } + /** + * Is this request addressed to the loopback interface by name? + * + * Binding to 127.0.0.1 does not stop a browser reaching the app: an attacker + * page on evil.com whose DNS re-resolves to 127.0.0.1 is then *same-origin* + * with this server as far as the browser is concerned, so it sends no Origin + * on a GET and can read every response. The origin rule cannot catch that — + * there is no origin to judge. The Host header can: a rebound request still + * carries `evil.com`, because that is the name the page was fetched from. + */ + function isAllowedHost(raw: string): boolean { + const host = raw.toLowerCase(); + if (allowedHosts.has(host)) return true; + // Strip the port; an IPv6 literal is bracketed, so scan past the bracket. + const name = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : (host.split(":")[0] ?? ""); + if (name === "localhost" || name === "[::1]" || name === "::1") return true; + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name); + } + function isForeignOrigin(req: A2AppRequest): boolean { const origin = req.headers["origin"]; if (origin === undefined) return false; // a program, not a browser @@ -592,7 +621,10 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { // Guard the RAW body before any backend coercion. Required-field presence is // enforced on CREATE only (POST) — a PATCH is a legitimate partial write. - const allow: Record = {}; + // Null prototype: `allow` is probed with keys from the request body, and on a + // plain object `allow["constructor"]` is truthy, which makes validate() skip + // that field entirely rather than guard it. + const allow: Record = Object.create(null) as Record; for (const k of def.writeAllow ?? []) allow[k] = true; const violations = validate(def.fields, body, { allow, requireRequired: req.method === "POST" }); if (violations.length) { @@ -884,6 +916,13 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { async function handle(req: A2AppRequest): Promise { const path = req.path.replace(/\/+$/, "") || "/"; + // Before any route, including the unauthenticated identity document, which + // is otherwise a free fingerprint of the app for a rebound page. + const hostHeader = req.headers["host"]; + if (hostHeader !== undefined && !isAllowedHost(hostHeader)) { + return err(403, "forbidden_host", `Refused: this app answers only on its own host, not "${hostHeader}".`); + } + // Identity (unauthenticated). if (path === "/.well-known/a2app.json" || path === "/api/_a2app") { // The update watcher polls this every few seconds anyway, so its "I am diff --git a/adapters/adapter-core/src/types.ts b/adapters/adapter-core/src/types.ts index cca23fe..4af3916 100644 --- a/adapters/adapter-core/src/types.ts +++ b/adapters/adapter-core/src/types.ts @@ -238,6 +238,9 @@ export interface A2AppConfig { conventions?: Record; /** the app's own origins; a write from any other Origin is refused 403. */ allowedOrigins?: string[]; + /** Host names to answer on, beyond loopback and the hosts of allowedOrigins. + * Set this only when the app is deliberately reachable under another name. */ + allowedHosts?: string[]; /** declared event types. */ events?: EventTypeDecl[]; /** injectable clock, for deterministic tests. */ diff --git a/adapters/adapter-core/test/host.test.mjs b/adapters/adapter-core/test/host.test.mjs new file mode 100644 index 0000000..1a3a1af --- /dev/null +++ b/adapters/adapter-core/test/host.test.mjs @@ -0,0 +1,63 @@ +/** + * Host validation, exercised over a raw socket. + * + * This cannot live in the conformance suite: that harness drives the CLI and + * fetch, and fetch forbids setting `Host` — the header this rule is about. A + * rebinding attacker is a browser and sets it implicitly, so the only faithful + * client here is one that writes the request itself. + */ +import { createServer, request } from "node:http"; +import { createA2App, MemoryBinding } from "../dist/index.js"; +import { a2appMiddleware } from "../dist/http.js"; + +const failures = []; +const check = (label, got, want) => { + if (got !== want) failures.push(`${label}\n expected: ${want}\n actual: ${got}`); +}; + +const app = createA2App( + new MemoryBinding({ + appId: "host_test", + appName: "Host Test", + entities: { notes: { module: "desk", fields: [{ name: "title", type: "string", required: true }], seed: [] } }, + }), + { + modules: [{ name: "desk", summary: "notes" }], + allowedOrigins: ["http://localhost:9931", "http://127.0.0.1:9931"], + }, +); + +const mw = a2appMiddleware(app); +const server = createServer((req, res) => + mw(req, res, () => { res.writeHead(404); res.end("{}"); }), +); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +const port = server.address().port; + +const get = (hostHeader) => + new Promise((resolve) => { + const req = request( + { host: "127.0.0.1", port, path: "/api/_a2app", method: "GET", headers: { host: hostHeader } }, + (res) => { res.resume(); resolve(res.statusCode); }, + ); + req.on("error", () => resolve(-1)); + req.end(); + }); + +check("a rebound foreign host is refused", await get("evil.example.com"), 403); +check("a foreign host with the right port is still refused", await get(`evil.example.com:${port}`), 403); +check("the loopback address is served", await get(`127.0.0.1:${port}`), 200); +check("the loopback name is served", await get(`localhost:${port}`), 200); +check("an IPv6 loopback literal is served", await get(`[::1]:${port}`), 200); +check("another 127.x address is served", await get(`127.0.0.2:${port}`), 200); +// A host that only starts with an allowed name must not pass. +check("a lookalike host is refused", await get("localhost.evil.example.com"), 403); + +server.close(); + +if (failures.length) { + console.log(`host validation: ${failures.length} check(s) FAILED\n`); + for (const f of failures) console.log(` [FAIL] ${f}\n`); + process.exit(1); +} +console.log("host validation: all checks passed"); diff --git a/adapters/adapter-starter/src/index.ts b/adapters/adapter-starter/src/index.ts index e064e84..c0ba928 100644 --- a/adapters/adapter-starter/src/index.ts +++ b/adapters/adapter-starter/src/index.ts @@ -78,7 +78,9 @@ export function startNotesApp(port = 8091, token = "a2app_starter_token"): Retur allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`], }); const server = createA2AppServer(app); - server.listen(port, () => process.stdout.write(`notes adapter on http://127.0.0.1:${port}\n`)); + // Loopback explicitly: `listen(port)` alone binds every interface, which this + // line already claimed it did not. + server.listen(port, "127.0.0.1", () => process.stdout.write(`notes adapter on http://127.0.0.1:${port}\n`)); return server; } diff --git a/adapters/reverse-proxy/src/index.ts b/adapters/reverse-proxy/src/index.ts index abce02e..3ec63ab 100644 --- a/adapters/reverse-proxy/src/index.ts +++ b/adapters/reverse-proxy/src/index.ts @@ -182,6 +182,9 @@ export function createReverseProxy(cfg: ReverseProxyConfig, credentials: Grant[] export function startReverseProxy(cfg: ReverseProxyConfig, port: number, token: string): ReturnType { const grant: Grant = { token, credentialId: "cred_proxy", agentName: "local", principal: "owner", scopes: ["*"] }; const server = createA2AppServer(createReverseProxy(cfg, [grant])); - server.listen(port, () => process.stdout.write(`reverse-proxy adapter for ${cfg.appId} on http://127.0.0.1:${port}\n`)); + // Loopback explicitly: `listen(port)` alone binds every interface, which this + // line already claimed it did not. A sidecar carrying an owner-scoped grant + // must not be the thing that puts the app on the network. + server.listen(port, "127.0.0.1", () => process.stdout.write(`reverse-proxy adapter for ${cfg.appId} on http://127.0.0.1:${port}\n`)); return server; } diff --git a/adapters/rules/src/index.ts b/adapters/rules/src/index.ts index 8e53a1c..463b82c 100644 --- a/adapters/rules/src/index.ts +++ b/adapters/rules/src/index.ts @@ -196,8 +196,16 @@ export function isDayKeyField(field: Pick { - const m: Record = {}; + const m: Record = Object.create(null) as Record; for (const f of fields) m[f.name] = f; return m; } @@ -244,11 +252,18 @@ export function validate( const map = fieldMap(fields); const writable = writableNames(fields); const out: Violation[] = []; + // Every lookup below is keyed by a name taken from the request body, so it + // must be an OWN-property test. `allow["constructor"]` and `map["toString"]` + // are truthy on any plain object, which let a field named after an + // Object.prototype member skip the allow check, read as declared, and pass + // every remaining rule — the guard silently voided by the field's name. + const has = (o: Record | Record, k: string): boolean => + Object.prototype.hasOwnProperty.call(o, k); for (const key of Object.keys(body)) { - if (allow[key]) continue; + if (has(allow, key) && allow[key]) continue; - const field = map[key]; + const field = has(map, key) ? map[key] : undefined; // Unknown fields are silently dropped by most backends and absent from the // response, so the caller cannot tell they were ignored. diff --git a/conformance/suites/class-a-core.yaml b/conformance/suites/class-a-core.yaml index 02904a7..d6d9639 100644 --- a/conformance/suites/class-a-core.yaml +++ b/conformance/suites/class-a-core.yaml @@ -169,6 +169,24 @@ checks: match: { a2app: true, ok: false } len: { violations: 3 } + # A field named after an Object.prototype member must not read as declared. + # On a plain-object field map `map["constructor"]` is truthy, which passed the + # unknown-field check and every rule after it, so the key reached the backend. + - name: a field named after a prototype member is still unknown + request: + method: POST + path: /api/collections/cards/records + token: full + body: { title: "x", constructor: "evil" } + expect: + status: 400 + match: + a2app: true + ok: false + violations.0.code: unknown_field + violations.0.field: constructor + len: { violations: 1 } + - name: guard writes to a read-only field are refused request: method: POST diff --git a/framework/cli/package.json b/framework/cli/package.json index d299b13..0c83d58 100644 --- a/framework/cli/package.json +++ b/framework/cli/package.json @@ -35,7 +35,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs", + "test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs && node test/containment.test.mjs", "bundle-blueprints": "node scripts/bundle-blueprints.mjs", "bundle-skills": "node scripts/bundle-skills.mjs", "bundle": "node scripts/bundle-blueprints.mjs && node scripts/bundle-skills.mjs", diff --git a/framework/cli/src/commands/scaffold.ts b/framework/cli/src/commands/scaffold.ts index 056b825..dcf090c 100644 --- a/framework/cli/src/commands/scaffold.ts +++ b/framework/cli/src/commands/scaffold.ts @@ -80,6 +80,7 @@ export async function run(args: string[], app: string): Promise { throw new UsageError(err instanceof Error ? err.message : String(err)); } vendorPaths(tk, dir, allTemplateFiles(tk)); + ensureIgnoreRules(dir, tk.manifest.lifecycle?.dataDir); recordProjectToolkit(dir, tk); systemPaths = tk.manifest.systemPaths; adapterVersion = adapterVersionOf(tk); @@ -259,29 +260,52 @@ function allTemplateFiles(_tk: ResolvedToolkit): string[] { return ["."]; } +/** + * Guarantee the ignore rules that keep runtime artifacts out of a repository. + * + * Blueprints ship their own `.gitignore`, but npm strips dotfiles named + * `.gitignore` out of a published tarball, so a CLI installed with `npm i -g` + * scaffolds an app carrying none at all — and the next `git add -A` publishes + * the agent token, the principal password and the live database. The rules the + * framework depends on are therefore written here, from the CLI, on every path. + * + * Existing lines are kept and only missing ones appended: a blueprint's own + * rules (node_modules, the PocketBase binary, __pycache__) are none of the + * framework's business, and re-running must not churn the file. + */ +function ensureIgnoreRules(dir: string, dataDir?: string): void { + const rules = [ + ".agent-token", + ".principal", + ".superuser", + ".a2app/serve.json", + ".a2app/serve.log", + ".a2app/dev/", + ".a2app/backups/", + ]; + // The database lives wherever the toolkit says it does, so the rule follows + // the declaration rather than assuming "data/". + if (dataDir !== undefined && dataDir.trim() !== "") { + rules.push(dataDir.trim().replace(/[\/]+$/, "") + "/"); + } + const file = join(dir, ".gitignore"); + const existing = existsSync(file) ? readFileSync(file, "utf8") : ""; + const present = new Set(existing.split(/\r?\n/).map((line) => line.trim())); + const missing = rules.filter((rule) => !present.has(rule)); + if (missing.length === 0) return; + const header = [ + "# Written by agent-app: credentials, framework state and the live database", + "# are runtime artifacts and must never be committed.", + ]; + const prefix = existing.trim() === "" ? "" : existing.replace(/\s*$/, "") + "\n\n"; + writeFileSync(file, prefix + [...header, ...missing, ""].join("\n")); +} + /** A stack-free skeleton: just the framework files, so `scaffold` without a * blueprint still yields a conforming artifact shell an agent then fills in. */ function scaffoldMinimal(dir: string, name: string): void { mkdirSync(join(dir, "reference"), { recursive: true }); - // Credentials are runtime artifacts that must never be committed (section 4.1). - // A blueprint ships its own ignore rules; a hand-assembled app has no other - // source for them, and the token is minted into this directory moments later. - writeFileSync( - join(dir, ".gitignore"), - [ - "# Credentials are runtime artifacts: never shipped, exported, or committed.", - ".agent-token", - ".principal", - ".superuser", - "", - "# Framework runtime state: serve records, logs, dev copies, and backups.", - ".a2app/serve.json", - ".a2app/serve.log", - ".a2app/dev/", - ".a2app/backups/", - "", - ].join("\n"), - ); + ensureIgnoreRules(dir); writeFileSync( join(dir, "operations.json"), JSON.stringify({ operations: [] }, null, 2) + "\n", diff --git a/framework/cli/src/lib/fsx.ts b/framework/cli/src/lib/fsx.ts index b4f8b02..ac9982d 100644 --- a/framework/cli/src/lib/fsx.ts +++ b/framework/cli/src/lib/fsx.ts @@ -22,7 +22,7 @@ import { statSync, symlinkSync, } from "node:fs"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; /** A monotonic-per-process suffix so staged temp names never collide, without * relying on wall-clock time. */ @@ -31,6 +31,41 @@ function tmpSuffix(): string { return `${process.pid}-${tmpCounter++}`; } +/** + * Physical containment: lexically inside `base` AND not reached through a + * symlink. + * + * {@link assertInside} compares strings, so a path with no `..` and no drive + * letter passes even when a component of it is a link pointing elsewhere. An + * app directory is untrusted input — `import` exists to take one from a + * stranger, and tar, zip and git all carry links (a Windows junction needs no + * privilege at all) — so a template file named `hooks/authorized_keys`, with + * `hooks` a link to ~/.ssh, was written straight through the link and + * overwrote a file the project has no business touching. + * + * Every component below `base` is checked. `base` itself may legitimately be a + * link: the user chose where their project lives. + */ +export function assertRealInside(base: string, candidate: string, label = "path"): string { + const target = assertInside(base, candidate, label); + const root = resolve(base); + let cur = root; + for (const segment of relative(root, target).split(sep)) { + if (segment === "") continue; + cur = join(cur, segment); + let st; + try { + st = lstatSync(cur); + } catch { + break; // nothing here yet, so nothing to traverse + } + if (st.isSymbolicLink()) { + throw new Error(`${label} "${candidate}" leaves ${root} through a link at ${cur}`); + } + } + return target; +} + /** * Resolve `candidate` against `base` and throw if it escapes `base` (a `..` * traversal or an absolute path pointing elsewhere). Returns the absolute, @@ -106,6 +141,13 @@ export function copyTree(src: string, dest: string): void { } if (!st.isDirectory()) { mkdirSync(dirname(dest), { recursive: true }); + // Replace a link rather than write through it: copyFileSync follows the + // destination, so a link left in the tree redirects the write outside it. + try { + if (lstatSync(dest).isSymbolicLink()) rmSync(dest, { force: true }); + } catch { + /* nothing there yet */ + } copyFileSync(src, dest); return; } diff --git a/framework/cli/src/lib/toolkit.ts b/framework/cli/src/lib/toolkit.ts index 1522efc..41af229 100644 --- a/framework/cli/src/lib/toolkit.ts +++ b/framework/cli/src/lib/toolkit.ts @@ -24,7 +24,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { assertInside, copyTree } from "./fsx.js"; +import { assertInside, assertRealInside, copyTree } from "./fsx.js"; export interface GateStep { name: string; @@ -132,7 +132,7 @@ export function vendorPaths(tk: ResolvedToolkit, projectDir: string, paths: stri // the project. assertInside throws on a `../` or absolute escape. const src = assertInside(templateRoot, rel, "template path"); if (!existsSync(src)) continue; - const dest = assertInside(projectDir, rel, "vendored path"); + const dest = assertRealInside(projectDir, rel, "vendored path"); mkdirSync(dirname(dest), { recursive: true }); copyTree(src, dest); written.push(rel); diff --git a/framework/cli/test/containment.test.mjs b/framework/cli/test/containment.test.mjs new file mode 100644 index 0000000..d2f0164 --- /dev/null +++ b/framework/cli/test/containment.test.mjs @@ -0,0 +1,80 @@ +/** + * Containment: a write aimed into a project must land in that project. + * + * `assertInside` compares strings, which is not the same question. An app + * directory is untrusted — `import` exists to take one from a stranger, and + * tar, zip and git all carry links — so a path with no `..` in it can still be + * a link pointing at the user's home directory. This file pins the physical + * check, and the copy that must not follow a link it finds at the destination. + * + * Symlink creation needs a privilege Windows does not grant by default; when it + * is unavailable the cases that need one are skipped rather than failed, so + * this still runs everywhere (CI is Linux, where they always work). + */ +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertInside, assertRealInside, copyTree } from "../dist/lib/fsx.js"; + +const failures = []; +let skipped = 0; +const check = (label, got, want) => { + if (got !== want) failures.push(`${label}\n expected: ${want}\n actual: ${got}`); +}; +const throws = (label, fn) => { + try { fn(); failures.push(`${label}\n expected: a thrown error\n actual: completed`); } + catch { /* expected */ } +}; + +const base = mkdtempSync(join(tmpdir(), "containment-")); +const project = join(base, "project"); +const outside = join(base, "outside"); +mkdirSync(project, { recursive: true }); +mkdirSync(outside, { recursive: true }); +writeFileSync(join(outside, "authorized_keys"), "ORIGINAL"); + +// Lexical containment still rejects the obvious escapes. +throws("a parent-relative path is refused", () => assertInside(project, "../outside/x", "p")); +throws("an absolute path elsewhere is refused", () => assertInside(project, join(outside, "x"), "p")); +check("an ordinary relative path is accepted", + typeof assertInside(project, "reference/requirements.md", "p"), "string"); +check("the physical check agrees on an ordinary path", + typeof assertRealInside(project, "reference/requirements.md", "p"), "string"); + +let links = true; +try { + symlinkSync(outside, join(project, "hooks"), "junction"); +} catch { + links = false; + skipped += 2; +} + +if (links) { + // The whole point: lexically clean, physically outside. + throws("a path reached through a linked directory is refused", () => + assertRealInside(project, "hooks/authorized_keys", "vendored path")); + + // And a copy must replace a link at the destination, not write through it. + const src = join(base, "payload.txt"); + writeFileSync(src, "VENDORED"); + const linkedDest = join(project, "linked-file"); + try { + symlinkSync(join(outside, "authorized_keys"), linkedDest, "file"); + copyTree(src, linkedDest); + check("the file outside is untouched", readFileSync(join(outside, "authorized_keys"), "utf8"), "ORIGINAL"); + check("the destination is no longer a link", lstatSync(linkedDest).isSymbolicLink(), false); + check("the destination holds the copied bytes", readFileSync(linkedDest, "utf8"), "VENDORED"); + } catch (e) { + if (e && e.code === "EPERM") skipped += 3; + else throw e; + } +} + +rmSync(base, { recursive: true, force: true }); + +if (failures.length) { + console.log(`containment: ${failures.length} check(s) FAILED\n`); + for (const f of failures) console.log(` [FAIL] ${f}\n`); + process.exit(1); +} +console.log(`containment: all checks passed${skipped ? ` (${skipped} skipped: symlinks unavailable)` : ""}`); diff --git a/toolkits/blueprint-python-fastapi/template/main.py b/toolkits/blueprint-python-fastapi/template/main.py index 415ca82..85cd457 100644 --- a/toolkits/blueprint-python-fastapi/template/main.py +++ b/toolkits/blueprint-python-fastapi/template/main.py @@ -50,12 +50,56 @@ def _agent_token() -> str: app = FastAPI(title=manifest["name"]) +# A protocol write is tiny; anything larger is a mistake or an attack. Reading +# the body happens before the origin, credential and rate checks — the path does +# not even have to exist — so an unauthenticated caller must never be able to +# make the app buffer an arbitrary amount of memory. adapter-core caps at the +# same size for the same reason (adapters/adapter-core/src/http.ts). +MAX_REQUEST_BODY_BYTES = 5 * 1024 * 1024 + + +async def _read_body(request: Request): + """Parse the request body under a cap. + + Returns ``(body, None)``, or ``(None, reply)`` when the body is too large. + Bytes are counted as they arrive rather than trusting Content-Length, so a + client that lies about the length gains nothing. + """ + size = 0 + chunks = [] + async for chunk in request.stream(): + size += len(chunk) + if size > MAX_REQUEST_BODY_BYTES: + return None, ( + 413, + { + "a2app": True, + "ok": False, + "code": "payload_too_large", + "message": f"Request body exceeds the {MAX_REQUEST_BODY_BYTES}-byte limit.", + "limitBytes": MAX_REQUEST_BODY_BYTES, + }, + ) + chunks.append(chunk) + raw = b"".join(chunks) + if not raw.strip(): + return {}, None + try: + return json.loads(raw), None + except ValueError: + # Hand the guard something it can reject by its own rules, rather than + # raising into a 500 that tells the caller nothing. Same envelope as + # adapter-core's parser. + return {"__unparsed__": raw.decode("utf8", "replace")}, None + + @app.api_route("/api/{path:path}", methods=["GET", "POST", "PATCH", "DELETE"]) async def a2app_route(path: str, request: Request): body = None if request.method in ("POST", "PATCH"): - raw = await request.body() - body = json.loads(raw) if raw else {} + body, too_large = await _read_body(request) + if too_large is not None: + return JSONResponse(status_code=too_large[0], content=too_large[1]) status, payload = adapter.dispatch( request.method, f"/api/{path}", dict(request.headers), body, dict(request.query_params) ) diff --git a/toolkits/blueprint-react-node/template/.gitignore b/toolkits/blueprint-react-node/template/.gitignore index 6ff15a0..4a1ab24 100644 --- a/toolkits/blueprint-react-node/template/.gitignore +++ b/toolkits/blueprint-react-node/template/.gitignore @@ -1,4 +1,15 @@ -node_modules/ -data/db.json +# Credentials are runtime artifacts: never shipped, exported, or committed. .agent-token .principal +.superuser + +# The live database and adapter-owned state (the toolkit's lifecycle dataDir). +data/ + +# Framework runtime state: serve records, logs, dev copies, and backups. +.a2app/serve.json +.a2app/serve.log +.a2app/dev/ +.a2app/backups/ + +node_modules/ diff --git a/toolkits/blueprint-react-node/template/server.mjs b/toolkits/blueprint-react-node/template/server.mjs index 469eeed..354cc26 100644 --- a/toolkits/blueprint-react-node/template/server.mjs +++ b/toolkits/blueprint-react-node/template/server.mjs @@ -247,6 +247,12 @@ const app = createA2App(binding, { // Any path the adapter does not own falls through to the View. const server = createA2AppServer(app, view.handler); -server.listen(PORT, () => { - process.stdout.write(`Agent App "${manifest.name ?? manifest.id}" on http://localhost:${PORT} (A2App id ${manifest.id})\n`); +// Bind loopback explicitly. `listen(PORT)` alone binds every interface, so the +// app was reachable from the network while its own log line said localhost -- +// and a same-origin request is trusted as the owner without a credential, which +// made a scaffolded Agent App remotely writable by anyone who could reach the +// port. Exposing it must be a deliberate act, hence the env var. +const HOST = process.env.A2APP_HOST ?? "127.0.0.1"; +server.listen(PORT, HOST, () => { + process.stdout.write(`Agent App "${manifest.name ?? manifest.id}" on http://${HOST}:${PORT} (A2App id ${manifest.id})\n`); });