From the 2026-08 security pass (a manual audit of crypto, ID/token, SQL/FTS, path, and merge-patch surfaces — no high/critical findings). Two low-severity hardening items that share a theme: core has no defenses at the untrusted-input boundary, which is fine while Stack is embedded/full-trust but becomes relevant the moment the server accepts arbitrary PATCH/POST bodies.
1. Reserved content keys (__proto__ / constructor / prototype)
Undeclared content fields pass validation by design, and applyMergePatch (packages/core/src/merge.ts) does merged[key] = value. A patch key of __proto__ invokes the prototype setter on the merged object rather than setting a field.
This is not a global prototype-pollution gadget — I traced the round-trip and it doesn't reach Object.prototype:
- On write:
JSON.stringify(merged) serializes only own-enumerable properties, so a reassigned prototype is dropped — the __proto__ patch key is silently lost, not stored or propagated.
- On read:
JSON.parse(row.content) creates an own __proto__ property (spec'd CreateDataProperty, bypasses the setter), so a stored {"__proto__": …} round-trips inertly.
So the actual defect is surprising, silent behavior (a merge-patch to __proto__ vanishes; the same key via full create() stores as an own property, so the two paths disagree), not an exploit. But defensive key-filtering at this boundary is cheap insurance against a future refactor turning the shallow merge into something deeper, and against the inconsistency itself.
Direction: reject __proto__, constructor, prototype as top-level content keys (a StackValidationError), or skip them in applyMergePatch with Object.prototype.hasOwnProperty/null-prototype accumulator. Rejecting is more honest than silently skipping. Decide whether this is a core invariant (in Stack.create/update, so every adapter inherits it — consistent with the #67/#68 layering) or a server-only concern.
2. No record-body size ceiling
maxAttachmentBytes covers attachment bytes only. Record content and PATCH bodies have no size limit anywhere in core, and nothing upstream of the adapter's JSON.parse guards one. An unbounded body is a trivial DoS (parse + store + FTS-index a multi-MB blob).
This is standard server request-size-limit territory — but it should be stated, because the spec is otherwise careful about resource bounds (validation depth cap, queryAllPages max, GC grace). Options: a spec sentence in the wire format directing servers to set a request-size limit and map overflow to 413/payload_too_large (reusing the existing code, currently attachment-only); optionally a soft maxContentBytes discovery capability so clients can pre-check like they do for attachments.
Work items
Cross-refs: security pass 2026-08; #67/#68 (invariant layering), the wire error taxonomy (#53) for the 413 reuse.
From the 2026-08 security pass (a manual audit of crypto, ID/token, SQL/FTS, path, and merge-patch surfaces — no high/critical findings). Two low-severity hardening items that share a theme: core has no defenses at the untrusted-input boundary, which is fine while
Stackis embedded/full-trust but becomes relevant the moment the server accepts arbitrary PATCH/POST bodies.1. Reserved content keys (
__proto__/constructor/prototype)Undeclared content fields pass validation by design, and
applyMergePatch(packages/core/src/merge.ts) doesmerged[key] = value. A patch key of__proto__invokes the prototype setter on the merged object rather than setting a field.This is not a global prototype-pollution gadget — I traced the round-trip and it doesn't reach
Object.prototype:JSON.stringify(merged)serializes only own-enumerable properties, so a reassigned prototype is dropped — the__proto__patch key is silently lost, not stored or propagated.JSON.parse(row.content)creates an own__proto__property (spec'dCreateDataProperty, bypasses the setter), so a stored{"__proto__": …}round-trips inertly.So the actual defect is surprising, silent behavior (a merge-patch to
__proto__vanishes; the same key via fullcreate()stores as an own property, so the two paths disagree), not an exploit. But defensive key-filtering at this boundary is cheap insurance against a future refactor turning the shallow merge into something deeper, and against the inconsistency itself.Direction: reject
__proto__,constructor,prototypeas top-level content keys (aStackValidationError), or skip them inapplyMergePatchwithObject.prototype.hasOwnProperty/null-prototype accumulator. Rejecting is more honest than silently skipping. Decide whether this is a core invariant (inStack.create/update, so every adapter inherits it — consistent with the #67/#68 layering) or a server-only concern.2. No record-body size ceiling
maxAttachmentBytescovers attachment bytes only. Recordcontentand PATCH bodies have no size limit anywhere in core, and nothing upstream of the adapter'sJSON.parseguards one. An unbounded body is a trivial DoS (parse + store + FTS-index a multi-MB blob).This is standard server request-size-limit territory — but it should be stated, because the spec is otherwise careful about resource bounds (validation depth cap,
queryAllPagesmax, GC grace). Options: a spec sentence in the wire format directing servers to set a request-size limit and map overflow to413/payload_too_large(reusing the existing code, currently attachment-only); optionally a softmaxContentBytesdiscovery capability so clients can pre-check like they do for attachments.Work items
413reuse for oversized non-attachment bodiesmaxContentBytesdiscovery capability parallelingmaxAttachmentBytesCross-refs: security pass 2026-08; #67/#68 (invariant layering), the wire error taxonomy (#53) for the 413 reuse.