From 4604634ea034632c51ed166151b36e893a44c6db Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:26:27 -0400 Subject: [PATCH 1/7] =?UTF-8?q?engine=200.4.47:=20close=20two=20live=20Cod?= =?UTF-8?q?eQL=20alerts=20on=20main=20=E2=80=94=20property=20injection=20+?= =?UTF-8?q?=20log=20forgery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both alerts were open against origin/main @ dbe854f, not introduced by any PR. HIGH js/remote-property-injection (alert 34, ts/src/sparql.ts:628) A Binding is a plain `{}` keyed by SPARQL variable names, and the tokenizer accepts /\?[A-Za-z0-9_]+/ — so `?__proto__` and `?constructor` are expressible verbatim in a query body POSTed to /api/graph/sparql. That reaches Object.prototype three ways: WRITE `out['__proto__'] = v` hits the inherited setter instead of defining a property, so a variable the response DECLARES is absent from every row. READ `sol['constructor']` returns an inherited FUNCTION, which escapes into results as if it were a binding value. `in` `'__proto__' in next` is true on a binding that bound nothing, so unify() compares against Object.prototype, fails, and the query returns ZERO rows. Fix: intermediate bindings are null-prototype dictionaries (new ts/src/safe-dict.ts), and output rows are materialized with Object.fromEntries — CreateDataProperty, so a hostile name lands as an own data property. Names are NOT rejected: SPARQL says any name is a legal variable, so rejecting would trade one silently-wrong answer for another. The prior partial fix at the projection site guarded the write but not the read; both are closed now. Same pattern found and fixed in the siblings rather than left half-done: gremlin.ts has()/values()/order() read n.properties[key] with a traversal- supplied key — g.V().values("constructor") really did hand back the Object constructor. Own-property reads only. cypher.ts row + grounding construction, and `$param` lookup: an unsupplied $constructor resolved to Object.prototype.constructor. patternMatcher.ts groundings had the identical `in` bug, which is what made a Cypher variable named __proto__ drop matches. MEDIUM js/log-injection (alert 28, ts/src/super-peer.ts:318) The existing guard stripped only /[\r\n\t]+/. Verified still forgeable end-to-end: a SPARQL parse error embeds the offending token verbatim, so POST /query with a hostile literal put VT, FF, NEL (U+0085), LS/PS (U+2028/9), NUL and ANSI CSI straight into console.error — ESC[2K ESC[1G erases the real line and rewrites it, so an operator tailing the log sees only the forged text. Fix: new ts/src/log-safe.ts sanitizeLogValue() escapes \p{Cc} + \p{Zl} + \p{Zp} to a VISIBLE \xNN form (a reader can see something was neutralized) and bounds the field. Applied at both log boundaries: the request-error path and joinSwarm. Tests: ts/src/security-hardening7.test.ts, 12 cases. 9 of 12 fail against origin/main and all 12 pass here; the other 3 are invariant guards (global prototype never polluted, field bounded, ordinary text unmangled) that hold in both directions by design. Suite 401/401 (389 on main + 12), typecheck clean, dist rebuilt, check:dist 0. Version 0.4.47: 0.4.46 is being prepared concurrently on #33 (vendor graph). --- package.json | 2 +- ts/dist/index.d.mts | Bin 225886 -> 228016 bytes ts/dist/index.d.ts | Bin 225886 -> 228016 bytes ts/dist/index.js | Bin 426543 -> 428137 bytes ts/dist/index.mjs | Bin 417626 -> 419169 bytes ts/src/cypher.ts | 31 ++-- ts/src/gremlin.ts | 15 +- ts/src/index.ts | 1 + ts/src/log-safe.ts | 57 ++++++++ ts/src/patternMatcher.ts | 10 +- ts/src/safe-dict.ts | 68 +++++++++ ts/src/security-hardening7.test.ts | 223 +++++++++++++++++++++++++++++ ts/src/sparql.ts | 52 ++++--- ts/src/super-peer.ts | 9 +- ts/src/superpeer-service.ts | 5 +- 15 files changed, 434 insertions(+), 39 deletions(-) create mode 100644 ts/src/log-safe.ts create mode 100644 ts/src/safe-dict.ts create mode 100644 ts/src/security-hardening7.test.ts diff --git a/package.json b/package.json index de9183e..eff401d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@socioprophet/hellgraph", - "version": "0.4.46", + "version": "0.4.47", "description": "HellGraph \u2014 TypeScript OpenCog-compatible AtomSpace metagraph engine (PLN, ECAN, pattern matcher, SPARQL/Gremlin, SHACL, Atomese, StorageNode federation). Polyglot sibling of the Rust hellgraph crate.", "license": "UNLICENSED", "type": "commonjs", diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index eb636a242be5caceedfd2aaca59538d24b1bc6d2..4ce491f966e53338534076c7aa0669ca28d68e09 100644 GIT binary patch delta 2089 zcmZ8iO>bL86jjq*r3kS>Y>=)hp(g3`M=Djci7J%3NeGw})k#1!s2;y}^E~Q!=5=OX z9D|U%;|CN8vE~neK7}73fUm zu}M2i3d)j-5pUU3j+>1zR26T4hv3E`dlpCy!RD9k<@51oN7sboMGbv^^UT5^;vQ(M@Nv`uJ0I=GIL*-0kAy<)5O?=4)8fZAb-BLulPXMD**}>f9CFP-C&BSn@UC8|@pnsM`~M zxV%lD^?)z@T9xvu^r{+CV7Z3H!!{+>X#wWN(#O5VKm{(~3pd4Q<&to_O^3&C5{|yM zu&Q(&JzChsQl}BnS+q5GHqb znwE^kIVlv9ZNya~fq2l@a68P2TIYkTFy@gFiQM5ipd zac?h6c3K3Lw(A048H|kV@uk5Yt=VNI+a-J4!lRi_)=6R9p{j+MAP?eA(aAyY;BI#p zshT6lnslOr9vBv2*as>=3TR$1CTAWC$*_P(`4<^e72JrC&1l6KpqA_Cl|hcn5F>Mt zry`Fw7;pi=O;DtUv`$LJTuOr z&FGu7e=UtUEV6JZ4vGoV1nE#I)U@8DAAU6}Z%@1T_aE&Y>~`-x`gG?DxWE-3Gnk-5iRGZrBw4jXc!wP zw?_^V_DAZXzuW*UlaWw3U&!f%`IzHtXKAE7PP}J3Pu}@paY%Pvc_PBBC3&`>#$)To zHpAMB54XtHqXGN%Ed;N`#P)YDUS4|s;^oSp{na=0fBxg=mw$Tqt@+8n0DXSDy8Qmi Ee^t4^*8l(j delta 33 rcmV++0N($w^$p(S4S=)((DsJ`{{goF{{qWkx7%L>p%}N0cmpB=Co~Uw diff --git a/ts/dist/index.d.ts b/ts/dist/index.d.ts index eb636a242be5caceedfd2aaca59538d24b1bc6d2..4ce491f966e53338534076c7aa0669ca28d68e09 100644 GIT binary patch delta 2089 zcmZ8iO>bL86jjq*r3kS>Y>=)hp(g3`M=Djci7J%3NeGw})k#1!s2;y}^E~Q!=5=OX z9D|U%;|CN8vE~neK7}73fUm zu}M2i3d)j-5pUU3j+>1zR26T4hv3E`dlpCy!RD9k<@51oN7sboMGbv^^UT5^;vQ(M@Nv`uJ0I=GIL*-0kAy<)5O?=4)8fZAb-BLulPXMD**}>f9CFP-C&BSn@UC8|@pnsM`~M zxV%lD^?)z@T9xvu^r{+CV7Z3H!!{+>X#wWN(#O5VKm{(~3pd4Q<&to_O^3&C5{|yM zu&Q(&JzChsQl}BnS+q5GHqb znwE^kIVlv9ZNya~fq2l@a68P2TIYkTFy@gFiQM5ipd zac?h6c3K3Lw(A048H|kV@uk5Yt=VNI+a-J4!lRi_)=6R9p{j+MAP?eA(aAyY;BI#p zshT6lnslOr9vBv2*as>=3TR$1CTAWC$*_P(`4<^e72JrC&1l6KpqA_Cl|hcn5F>Mt zry`Fw7;pi=O;DtUv`$LJTuOr z&FGu7e=UtUEV6JZ4vGoV1nE#I)U@8DAAU6}Z%@1T_aE&Y>~`-x`gG?DxWE-3Gnk-5iRGZrBw4jXc!wP zw?_^V_DAZXzuW*UlaWw3U&!f%`IzHtXKAE7PP}J3Pu}@paY%Pvc_PBBC3&`>#$)To zHpAMB54XtHqXGN%Ed;N`#P)YDUS4|s;^oSp{na=0fBxg=mw$Tqt@+8n0DXSDy8Qmi Ee^t4^*8l(j delta 33 rcmV++0N($w^$p(S4S=)((DsJ`{{goF{{qWkx7%L>p%}N0cmpB=Co~Uw diff --git a/ts/dist/index.js b/ts/dist/index.js index 7c21a6f365becf6d2fec99c43f56fbeb480873fe..8abc1a0d61d044c529f5d64b080dcc085826bbfe 100644 GIT binary patch delta 2611 zcmbVOU1$_n6z1+`H%-(U?Z%Y&)9aS%jBaMvt+ALjCjO*SswRz8TQ^I*nZ4N^ot;@{ z?#9G4T`VnHf55WP>r-0_Rr;br9tKp9{yY^5r4)Q9rSzp(3YI|MYN6-OOg6KLzI0)C znR~u-?m6H2n_KU9{&BVQ_RDtfp& zRFd&@E5;UO70bZMEx3Plbm-0DfzeUCC0W~m)>LN}v{c*OZ=`u1X7#^!u6VM@nMEiQUftlF zG?~Ct9N&z@_{s=+d$miu@3|!BARazP$bVS`RG z2%*6@+tlm%5%R2XRso_!cIX7r#f5GJ3Y{!l&_$qt`}%61eG^+I6ujKc@N-wdl{?QM zggP3d0EGlD$#RCTY(i_Dhb^cY<%2&rMfv?NBh9?;^H7vuxr|y?mG?q_m=&g!D|S)J z783kw8)|QI1Ko2SSh(AURxbCY1X@4AQ>##HlK_YyeVT0#5lEW^MrG&UHuP0&d+->d z)X11lZKXuYk~HB~Om+fd%ueJWMl1$LPL`d4P3QqiLNk`#3h-MQ3ZUNLR1s2WP3SdG zoN+oNlnE^t=aS)_IqDYFA8vDxm~W*AGyc&kw9fgd3k~^jC`mCacy|h=JoH!{J;rav z&`n3$hE69@x|Vtvt9>#T<+CTygXSJAOsAcT1pOXCX@2?*w4Prnpv@@#v4%GA9}DRD zc@Y0qL$BAS@3-*XMfC0xaNQVxprH@q^*~us1A>V~N)V^}Ac{beWzJ5bt{0zzGfyeD zuBlYo25jCri9THu93oEk9LhBBoG;dTxz5+By8sr(2*yTMvyOTtTGotA?T6k@=jH`; zEL?lEBf3*K^mJ8G!A$%J^CWFE8-@R58VsJ&R@C_axUEnAKp>b%|e%0t&Lk1A}P~-CQNZtm(-#+=DluL z%{&z}6cx2dtbOpbhe;{7S3E$GP6{~)6lNZQHDMH_Z980m@40JYS1(R?cUN&Aj*pB? z#_?7RickXiBaB!~N+)nU6_;_35LcFnw|io-SD@YS!NCKguf8^LaR2C`J#WG7-6>dm z^_K*j7#h>g(t~Ef+nX?dDio#%nXCf1K{*(zMZ{F#rGn delta 1033 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 P@s)GxVjC|`sCnrhYcxw3 diff --git a/ts/src/cypher.ts b/ts/src/cypher.ts index 3c6ba92..f4b287a 100644 --- a/ts/src/cypher.ts +++ b/ts/src/cypher.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { AtomSpace, nodeHandle, linkHandle, type Atom, type Handle } from './atomspace' import { findMatches, V, N, L, type Pattern, type PatternTerm, type Grounding } from './patternMatcher' +import { cloneDict, emptyDict, ownValue, toPlainRow } from './safe-dict.js' /** * Cypher Facade v0.1 — a human/agent-friendly READ query surface over the AtomSpace. @@ -354,8 +355,10 @@ class Parser { } private resolveVal(tok: string): string { - if (tok === '$') return this.params[this.next()] ?? '' - if (tok?.startsWith('$')) return this.params[tok.slice(1)] ?? '' + // The parameter NAME is query-derived and `params` is caller-supplied, so an own-property + // read only: `$constructor` must be an unsupplied parameter, not Object.prototype.constructor. + if (tok === '$') return ownValue(this.params, this.next()) ?? '' + if (tok?.startsWith('$')) return ownValue(this.params, tok.slice(1)) ?? '' return unquote(tok) } } @@ -565,7 +568,9 @@ export function runCypher(as: AtomSpace, query: string, params: Record { - const rr: RRow = {} + // Keyed by Cypher variable names and `var.prop` paths, all query-derived — null-prototype + // so a variable named `__proto__` is stored rather than swallowed (see safe-dict.ts). + const rr: RRow = emptyDict() for (const [v, h] of Object.entries(g)) { if (v.startsWith('_')) continue const atom = as.getAtom(h) @@ -622,7 +627,7 @@ export function runCypher(as: AtomSpace, query: string, params: Record 1 || (pin !== undefined && !as.getAtom(pin))) { groundings = []; break } - if (pin !== undefined) groundings = groundings.map((g) => ({ ...g, [irVar]: pin })) + if (pin !== undefined) groundings = groundings.map((g) => { const n = cloneDict(g); n[irVar] = pin; return n }) else if (!clauseBound.has(irVar)) { scanVars.push({ irVar, labels: e.labels }); continue } for (const label of e.labels) groundings = groundings.filter((g) => atomMatchesLabel(as, as.getAtom(g[irVar]!), label)) } @@ -638,7 +643,9 @@ export function runCypher(as: AtomSpace, query: string, params: Record ({ col: c, ref: { var: c } })) : ast.ret.map((c) => ({ col: c.prop ? `${c.var}.${c.prop}` : c.var, ref: c })) const outCols = outRefs.map((c) => c.col) - let outRows = rows.map((r) => { - const o: Record = {} - for (const { col, ref } of outRefs) { const v = refValue(r, ref); o[col] = v === undefined ? '' : String(v) } - return o - }) + // Column names are query-derived (RETURN aliases). Materialize via toPlainRow so a hostile + // name lands as an own data property instead of hitting the inherited __proto__ setter and + // leaving a DECLARED column missing from every row. + let outRows = rows.map((r) => + toPlainRow(outRefs.map(({ col, ref }) => { + const v = refValue(r, ref) + return [col, v === undefined ? '' : String(v)] as const + })), + ) outRows = outRows.slice(0, Math.min(ast.limit ?? maxRows, maxRows)) opts.onEvidence?.({ queryHash, space: as.id, mode: opts.mode ?? 'operational', columns: outCols, rowCount: outRows.length, evaluatedAtSeq: as.logicalClock, useSpace: ast.useSpace }) diff --git a/ts/src/gremlin.ts b/ts/src/gremlin.ts index 38f5080..f942e8b 100644 --- a/ts/src/gremlin.ts +++ b/ts/src/gremlin.ts @@ -1,5 +1,12 @@ import type { HellGraphStore } from './store' import type { GraphNode, GraphEdge, GremlinResult, PropertyValue } from './types' +import { ownValue } from './safe-dict.js' + +/** Read a property whose KEY came from the traversal text. Own properties only: without this, + * `values("constructor")` / `values("toString")` hand the caller inherited Object.prototype + * FUNCTIONS as if they were stored graph data, and `has("constructor", …)` compares against + * one (js/remote-property-injection — see safe-dict.ts). */ +const prop = (e: GraphNode | GraphEdge, key: string): PropertyValue | undefined => ownValue(e.properties, key) /** * A Gremlin/TinkerPop-style traversal engine over HellGraph's property graph. @@ -29,7 +36,7 @@ export class GraphTraversal { } has(key: string, value: PropertyValue): GraphTraversal { - return this.derive(this.nodes().filter((n) => looseEq(n.properties[key], value))) + return this.derive(this.nodes().filter((n) => looseEq(prop(n, key), value))) } out(label?: string): GraphTraversal { @@ -55,7 +62,7 @@ export class GraphTraversal { // ─── Terminal-ish steps ────────────────────────────────────────────────── values(key: string): GraphTraversal { - const out = this.current.map((t) => (isNode(t) || isEdge(t)) ? t.properties[key] : t).filter((v) => v !== undefined) + const out = this.current.map((t) => (isNode(t) || isEdge(t)) ? prop(t, key) : t).filter((v) => v !== undefined) return this.derive(out as Traverser[]) } @@ -77,8 +84,8 @@ export class GraphTraversal { order(key: string, desc = false): GraphTraversal { const sorted = [...this.current].sort((a, b) => { - const av = isNode(a) || isEdge(a) ? a.properties[key] : a - const bv = isNode(b) || isEdge(b) ? b.properties[key] : b + const av = isNode(a) || isEdge(a) ? prop(a, key) : a + const bv = isNode(b) || isEdge(b) ? prop(b, key) : b const an = Number(av), bn = Number(bv) const cmp = !Number.isNaN(an) && !Number.isNaN(bn) ? an - bn : String(av ?? '').localeCompare(String(bv ?? '')) return desc ? -cmp : cmp diff --git a/ts/src/index.ts b/ts/src/index.ts index b3839e3..cc5341a 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -54,6 +54,7 @@ export * from './metta-eval' export * from './vendor-cache' export * from './metrics' export * from './rate-limit' +export * from './log-safe' export * from './discourse' export * from './graph-analytics' export * from './attribute-profile' diff --git a/ts/src/log-safe.ts b/ts/src/log-safe.ts new file mode 100644 index 0000000..9aafda5 --- /dev/null +++ b/ts/src/log-safe.ts @@ -0,0 +1,57 @@ +/** + * Log-boundary sanitizer. + * + * Every log line this estate writes is potential evidence: receipts, audit records and + * operator forensics are all read back as fact. A log line an attacker can *shape* is + * therefore worse here than in a typical application — it does not merely add noise, it + * lets an attacker author entries a later reader attributes to the system. + * + * A log record is line-oriented, so the whole attack reduces to "get a line terminator into + * a field". Stripping CR/LF alone is NOT enough. The previous guard in super-peer.ts did + * exactly that — `.replace(/[\r\n\t]+/g, ' ')` — and still let all of these through: + * + * U+000B VT, U+000C FF line breaks to many log viewers and pagers + * U+0085 NEL a C1 line break; a real terminator to Unicode-aware readers + * U+2028 LS, U+2029 PS line/paragraph separators; break lines in JS-based tooling + * U+0000 NUL truncates the record in C-string consumers + * U+001B ESC ANSI CSI: `ESC[2K ESC[1G` erases the real line and rewrites it, + * so an operator tailing the log sees the forged text and never + * sees what it replaced + * + * The rule is therefore a category allow-list, not a deny-list of specific characters: + * `\p{Cc}` (every C0 control, DEL, every C1 control — so NUL, ESC, VT, FF, NEL are all in) + * plus `\p{Zl}` / `\p{Zp}` (U+2028 / U+2029) are rendered as a VISIBLE escape. Visible + * rather than deleted, so a reader can tell something was neutralized instead of silently + * being handed a laundered line. + */ + +/** Render one control character as a visible, unambiguous escape. */ +function escapeControl(ch: string): string { + const code = ch.charCodeAt(0) + const hex = code.toString(16) + return code <= 0xff ? '\\x' + hex.padStart(2, '0') : '\\u' + hex.padStart(4, '0') +} + +/** Hard cap so one request cannot flood the log (and so a record stays greppable). */ +export const LOG_FIELD_MAX = 500 + +/** + * Render an untrusted value as a single-line, unforgeable log field. + * + * Guarantees, for any input whatsoever: + * - the result contains no character that can terminate, truncate or rewrite a log line + * - the result is at most `max` characters, plus an explicit truncation marker + */ +export function sanitizeLogValue(value: unknown, max: number = LOG_FIELD_MAX): string { + const raw = typeof value === 'string' ? value : String(value) + const escaped = raw + // CR and LF first, and individually. Besides being the obvious vector, this exact shape + // — a global replace of a constant string — is the barrier CodeQL's js/log-injection + // recognises. A quantified character class such as /[\r\n\t]+/g is not recognised, which + // is why the previous guard kept alerting even though it did remove newlines. + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + // Everything else that can break, truncate or rewrite a line. + .replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, escapeControl) + return escaped.length > max ? escaped.slice(0, max) + '[truncated]' : escaped +} diff --git a/ts/src/patternMatcher.ts b/ts/src/patternMatcher.ts index eed05d9..e9f31c8 100644 --- a/ts/src/patternMatcher.ts +++ b/ts/src/patternMatcher.ts @@ -1,4 +1,5 @@ import { AtomSpace, nodeHandle, type Atom, type Handle } from './atomspace' +import { cloneDict, emptyDict } from './safe-dict.js' /** * Pattern Matcher — native hypergraph query over the AtomSpace. @@ -46,7 +47,10 @@ export const L = (type: string, ...outgoing: PatternTerm[]): Extract()] for (const clause of pattern.clauses) { const next: Grounding[] = [] @@ -103,7 +107,9 @@ function unifyTerm(as: AtomSpace, term: PatternTerm, handle: Handle, binding: Gr const atom = as.getAtom(handle) if (!atom || !as.types.isA(atom.type, term.type)) return null } - return { ...binding, [term.name]: handle } + const bound = cloneDict(binding) + bound[term.name] = handle + return bound } case 'node': return nodeHandle(term.type, term.name) === handle ? binding : null diff --git a/ts/src/safe-dict.ts b/ts/src/safe-dict.ts new file mode 100644 index 0000000..58e77da --- /dev/null +++ b/ts/src/safe-dict.ts @@ -0,0 +1,68 @@ +/** + * Null-prototype dictionaries for untrusted keys. + * + * Every query surface in this engine keys a plain object BY NAMES THAT CAME OFF THE WIRE: + * SPARQL solution bindings by variable name, Cypher rows by variable / RETURN alias, Cypher + * `$params` by parameter name, Gremlin property lookups by property key, pattern-matcher + * groundings by pattern variable. On an ordinary `{}` every such name that collides with a + * member of Object.prototype is a live wire (js/remote-property-injection), in three distinct + * ways — all three are real, and the middle one is the easiest to miss: + * + * WRITE `row['__proto__'] = v` hits the inherited accessor instead of defining a property. + * The column is DECLARED in the response and then absent from every row: a + * silently-wrong result, not an error. + * + * READ `props['constructor']` / `props['toString']` return inherited FUNCTIONS. Those + * escape into query results as if they were data (`g.V().values("constructor")` + * really does hand back the Object constructor), and feed comparisons that then + * match rows no user ever stored. + * + * `in` `'__proto__' in binding` is true on an object that bound nothing. Unification + * then compares the candidate against Object.prototype, fails, and the query + * silently returns ZERO rows. + * + * A null-prototype object has no inherited members, so a variable named ?__proto__ or + * ?constructor is simply an ordinary variable — which is what every one of these query + * languages says it is. That is why this is the fix rather than rejecting such names: + * rejecting them would trade one silently-wrong answer for a different one. + * + * NB: object spread (`{ ...dict }`) and object literals re-attach Object.prototype. Use + * cloneDict / mergeDicts, never a spread, to carry one of these forward. + */ + +/** A dictionary whose keys are untrusted. Structurally a Record; semantically null-prototype. */ +export type SafeDict = Record + +/** A fresh dictionary with no inherited members. */ +export function emptyDict(): SafeDict { + return Object.create(null) as SafeDict +} + +/** Copy `src`'s own entries into a fresh null-prototype dictionary. */ +export function cloneDict(src: SafeDict): SafeDict { + return Object.assign(Object.create(null), src) as SafeDict +} + +/** Merge two dictionaries into a fresh null-prototype one; `b` wins collisions. */ +export function mergeDicts(a: SafeDict, b: SafeDict): SafeDict { + return Object.assign(Object.create(null), a, b) as SafeDict +} + +/** + * Read a key that came from untrusted input, from an object that may have a normal prototype. + * Returns undefined for anything not stored as an OWN property, so `constructor`, `toString` + * and friends read as "absent" rather than as inherited functions. + */ +export function ownValue(src: Record | undefined, key: string): V | undefined { + if (!src) return undefined + return Object.prototype.hasOwnProperty.call(src, key) ? src[key] : undefined +} + +/** + * Materialize an output row for consumers: a NORMAL-prototype object, so callers get a plain + * JSON-shaped value, but built with Object.fromEntries — which uses CreateDataProperty, so even + * a `__proto__` key lands as a real own data property instead of invoking the inherited setter. + */ +export function toPlainRow(entries: Iterable): Record { + return Object.fromEntries(entries) as Record +} diff --git a/ts/src/security-hardening7.test.ts b/ts/src/security-hardening7.test.ts new file mode 100644 index 0000000..9c40098 --- /dev/null +++ b/ts/src/security-hardening7.test.ts @@ -0,0 +1,223 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +process.env['HELLGRAPH_STORE_DIR'] = mkdtempSync(join(tmpdir(), 'hg-sec7-')) + +import { getHellGraph } from './store.js' +import { getAtomSpace } from './atomspace.js' +import { runSparql } from './sparql.js' +import { runGremlin } from './gremlin.js' +import { runCypher } from './cypher.js' +import { sanitizeLogValue, LOG_FIELD_MAX } from './log-safe.js' + +const g = getHellGraph() +g.addNode('n:1', ['Person'], { name: 'Ada', age: 30 }) +g.addNode('n:2', ['Person'], { name: 'Alan', age: 40 }) + +/** Keys that alias a member of Object.prototype — the whole attack surface in one list. */ +const HOSTILE_KEYS = ['__proto__', 'constructor', 'toString', 'valueOf', 'hasOwnProperty'] + +const ownKeys = (o: object): string[] => Object.getOwnPropertyNames(o) +const isOwnData = (o: object, k: string): boolean => { + const d = Object.getOwnPropertyDescriptor(o, k) + return d !== undefined && 'value' in d +} + +// ─── Attack 16: remote property injection through query-derived key names ────────────────── +// CodeQL js/remote-property-injection, alert 34 (ts/src/sparql.ts:628, HIGH). +// A SPARQL variable name is attacker-controlled: the tokenizer accepts /\?[A-Za-z0-9_]+/, so +// `?__proto__` and `?constructor` are expressible verbatim in a query body posted to +// /api/graph/sparql. On a plain-object Binding that reaches Object.prototype three ways — +// a swallowed write, an inherited read, and a spuriously-true `in`. All three are asserted. + +test('SECURITY: GROUP BY ?__proto__ writes an own data property and cannot clobber a prototype', () => { + const r = runSparql(g, 'SELECT ?__proto__ (COUNT(?s) AS ?n) WHERE { ?s ?p ?__proto__ } GROUP BY ?__proto__') + + assert.ok(r.bindings.length > 0, 'a variable named ?__proto__ must still match rows, not silently return zero') + for (const b of r.bindings) { + assert.ok(isOwnData(b, '__proto__'), '__proto__ must be stored as an OWN DATA property, not routed to the setter') + assert.equal(Object.getPrototypeOf(b), Object.prototype, 'the row prototype must be untouched') + assert.ok(JSON.stringify(b).includes('__proto__'), 'a DECLARED variable must appear in the serialized row') + } + // The declared variable list and the row keys must agree — the silent-wrong failure mode is a + // column promised in `variables` and then absent from every row. + for (const v of r.variables) { + assert.ok(r.bindings.every((b) => ownKeys(b).includes(v)), `declared variable ${v} missing from a row`) + } +}) + +test('SECURITY: an aggregate aliased to ?__proto__ lands in the row instead of being swallowed', () => { + const r = runSparql(g, 'SELECT (COUNT(?s) AS ?__proto__) WHERE { ?s ?p ?o }') + assert.deepEqual(r.variables, ['__proto__']) + assert.equal(r.bindings.length, 1) + const row = r.bindings[0]! + assert.ok(isOwnData(row, '__proto__'), 'COUNT(...) AS ?__proto__ must produce a real own property') + assert.equal(typeof (row as Record)['__proto__'], 'number', 'and it must hold the count') +}) + +test('SECURITY: an unbound hostile variable reads as null, never as an inherited function', () => { + for (const key of HOSTILE_KEYS) { + for (const q of [ + `SELECT ?${key} (COUNT(?s) AS ?n) WHERE { ?s ?p ?o } GROUP BY ?${key}`, // aggregate path (line 628) + `SELECT ?${key} WHERE { ?s ?p ?o }`, // plain projection path + ]) { + for (const b of runSparql(g, q).bindings) { + const v = (b as Record)[key] + assert.notEqual(typeof v, 'function', `?${key} leaked an inherited function into a binding (${q})`) + assert.equal(v, null, `?${key} is unbound and must read as null (${q})`) + } + } + } +}) + +test('SECURITY: a hostile variable name still unifies — matchTriple must not see an inherited key', () => { + // The `in` failure mode: `'__proto__' in binding` is true on a plain object that bound nothing, + // so unify() compares the candidate against Object.prototype, fails, and the query returns zero + // rows. `?__proto__` must behave exactly like any other variable name. + const hostile = runSparql(g, 'SELECT ?__proto__ WHERE { ?s ?p ?__proto__ }').bindings.length + const control = runSparql(g, 'SELECT ?ordinary WHERE { ?s ?p ?ordinary }').bindings.length + assert.equal(hostile, control, '?__proto__ must match the same rows as any ordinary variable name') + assert.ok(hostile > 0, 'and that must not be zero') +}) + +test('SECURITY: no query surface pollutes the global Object.prototype', () => { + const probe = () => ({} as Record) + for (const key of HOSTILE_KEYS) { + runSparql(g, `SELECT ?${key} (COUNT(?s) AS ?n) WHERE { ?s ?p ?${key} } GROUP BY ?${key}`) + runSparql(g, `SELECT (COUNT(?s) AS ?${key}) WHERE { ?s ?p ?o }`) + } + assert.equal(probe()['polluted'], undefined, 'Object.prototype must carry no attacker key') + assert.equal(Object.getPrototypeOf({}), Object.prototype) + assert.equal(typeof ({} as Record)['toString'], 'function', 'and must remain intact') +}) + +// ─── Attack 17: the same pattern in the sibling query surfaces ───────────────────────────── + +test('SECURITY: Gremlin values()/has() read own properties only — no inherited function leaks', () => { + for (const key of HOSTILE_KEYS) { + const vals = runGremlin(g, `g.V().values("${key}")`).values as unknown[] + assert.ok( + !vals.some((v) => typeof v === 'function'), + `g.V().values("${key}") handed back an Object.prototype function as if it were graph data`, + ) + assert.equal(vals.length, 0, `no node stores "${key}", so the traversal must yield nothing`) + // has() must not match on an inherited member either. + const matched = runGremlin(g, `g.V().has("${key}","x").values("name")`).values as unknown[] + assert.equal(matched.length, 0, `has("${key}", …) matched a node that never stored it`) + } +}) + +test('SECURITY: Cypher RETURN of a hostile variable yields own data, not a prototype member', () => { + const as = getAtomSpace() + // NB `__proto__` is included for the OWN-PROPERTY assertion only. Its projected value is '' + // because Cypher reserves a leading `_` for internal IR variables (`_leading` behaves + // identically) — that naming convention is pre-existing and unrelated to this alert. + for (const key of ['__proto__', 'constructor', 'toString', 'valueOf']) { + const r = runCypher(as, `MATCH (${key}:Person) RETURN ${key} LIMIT 5`) as { + columns: string[] + rows: Record[] + } + assert.ok(r.rows.length > 0, `MATCH (${key}:Person) must still match — a hostile name is just a name`) + for (const row of r.rows) { + assert.ok(isOwnData(row, key), `a DECLARED column ${key} must be an own data property of the row`) + assert.equal(Object.getPrototypeOf(row), Object.prototype, 'row prototype untouched') + assert.notEqual(typeof row[key], 'function', `${key} leaked an inherited function`) + } + } +}) + +test('SECURITY: an unsupplied Cypher $param named for a prototype member resolves empty', () => { + const as = getAtomSpace() + // Store a node whose name is EXACTLY what Object.prototype.constructor stringifies to. With a + // plain-object params lookup, `$constructor` resolves to that inherited function, stringifies, + // and matches this node — a row the caller never asked for, from a parameter never supplied. + const bait = String(Object) + getHellGraph().addNode('n:bait', ['Person'], { name: bait }) + + const injected = runCypher(as, 'MATCH (n:Person) WHERE n.name = $constructor RETURN n LIMIT 5', {}) as { + rows: Record[] + } + assert.equal(injected.rows.length, 0, 'an unsupplied $param must match nothing, not a stringified prototype function') + + // Control: a supplied parameter of the same name must still work normally. + const supplied = runCypher(as, 'MATCH (n:Person) WHERE n.name = $constructor RETURN n LIMIT 5', { + constructor: bait, + }) as { rows: Record[] } + assert.equal(supplied.rows.length, 1, 'a genuinely supplied parameter named "constructor" must still resolve') +}) + +// ─── Attack 18: log-entry forgery at the log boundary ────────────────────────────────────── +// CodeQL js/log-injection, alert 28 (ts/src/super-peer.ts:318, MEDIUM). Receipts and audit +// records in this estate are read back as evidence, so a log line an attacker can SHAPE lets +// them author entries a later reader attributes to the system. + +const C = String.fromCharCode +/** Every character class a terminal, log viewer or line-oriented parser may treat as a break. */ +const LINE_BREAKING = /[\p{Cc}\p{Zl}\p{Zp}]/u + +const FORGERY_VECTORS: [string, string][] = [ + ['LF', C(0x0a)], + ['CR', C(0x0d)], + ['CRLF', C(0x0d) + C(0x0a)], + ['VT (vertical tab)', C(0x0b)], + ['FF (form feed)', C(0x0c)], + ['NEL U+0085', C(0x85)], + ['LS U+2028', C(0x2028)], + ['PS U+2029', C(0x2029)], + ['NUL', C(0x00)], + ['ANSI CSI erase-line', C(0x1b) + '[2K' + C(0x1b) + '[1G'], +] + +test('SECURITY: no control character can forge a second log line', () => { + for (const [name, sep] of FORGERY_VECTORS) { + const payload = `benign${sep}[super-peer] audit: admitted=attacker` + const out = sanitizeLogValue(payload) + + assert.ok(!LINE_BREAKING.test(out), `${name}: a line-breaking/control character survived sanitization`) + assert.equal(out.split(/\r|\n/u).length, 1, `${name}: output split into more than one line`) + // The forged text may remain as inert characters — what must NOT survive is the separator + // that turns it into its own record. + assert.ok(out.startsWith('benign'), `${name}: the real field content must be preserved`) + assert.ok(out.includes('\\x') || out.includes('\\u') || out.includes('\\r') || out.includes('\\n'), + `${name}: the neutralized character must be VISIBLE, so a reader can see it was there`) + } +}) + +test('SECURITY: an attacker-shaped SPARQL parse error logs as exactly one line', () => { + // End-to-end shape of alert 28: POST /query with a hostile query, the parse error embeds the + // offending token verbatim, and that message is what super-peer's catch block logs. + for (const [, sep] of FORGERY_VECTORS) { + const query = `SELECT ?s WHERE { ?s ?p ?o } GROUP "x${sep}[super-peer] request error: none"` + let message = '' + try { + runSparql(g, query) + continue // parsed without error; nothing reaches the log for this vector + } catch (e) { + message = (e as Error).message + } + const logged = sanitizeLogValue(message) + assert.ok(!LINE_BREAKING.test(logged), 'the logged line must contain no line-breaking character') + assert.equal(logged.split(/\r|\n/u).length, 1, 'one error must produce exactly one log line') + } +}) + +test('SECURITY: log fields are bounded and non-string input is coerced safely', () => { + const flood = 'A'.repeat(50_000) + C(0x0a) + 'forged' + const out = sanitizeLogValue(flood) + assert.ok(out.length <= LOG_FIELD_MAX + '[truncated]'.length, 'a single field cannot flood the log') + assert.ok(!LINE_BREAKING.test(out)) + + for (const weird of [undefined, null, 42, { toString: () => 'x' + C(0x0a) + 'forged' }]) { + const s = sanitizeLogValue(weird) + assert.equal(typeof s, 'string') + assert.ok(!LINE_BREAKING.test(s), 'coerced non-string input must be sanitized too') + } +}) + +test('SECURITY: sanitizing leaves ordinary log text untouched', () => { + const ordinary = "SPARQL parse error: expected 'BY', got 'LIMIT' (offset 42) — 100% ok" + assert.equal(sanitizeLogValue(ordinary), ordinary, 'the sanitizer must not mangle normal messages') +}) diff --git a/ts/src/sparql.ts b/ts/src/sparql.ts index 553a478..19194d1 100644 --- a/ts/src/sparql.ts +++ b/ts/src/sparql.ts @@ -1,5 +1,6 @@ import type { HellGraphStore } from './store' import type { Binding, PropertyValue, SparqlResult, Triple } from './types' +import { cloneDict, emptyDict, mergeDicts, toPlainRow } from './safe-dict.js' export type { Binding, SparqlResult } @@ -21,6 +22,17 @@ export type { Binding, SparqlResult } * restriction; then GROUP/aggregate or DISTINCT / ORDER / OFFSET / LIMIT / projection. */ +// ─── Solution bindings ───────────────────────────────────────────────────────── +// A Binding is keyed by SPARQL variable names, which come straight off the wire, so every +// intermediate binding is a null-prototype dictionary and output rows are materialized back +// onto a normal prototype at the boundary. See safe-dict.ts for why (js/remote-property-injection). +// NB: object spread `{ ...binding }` re-attaches Object.prototype — use cloneBinding/mergeBindings. + +const emptyBinding = (): Binding => emptyDict() +const cloneBinding = (b: Binding): Binding => cloneDict(b) +const mergeBindings = (a: Binding, b: Binding): Binding => mergeDicts(a, b) +const toResultRow = (entries: Iterable): Binding => toPlainRow(entries) + // ─── Term model ──────────────────────────────────────────────────────────────── type Term = @@ -412,7 +424,7 @@ function unquote(tok: string): string { // ─── Evaluator ─────────────────────────────────────────────────────────────── function matchTriple(pattern: TriplePattern, triple: Triple, binding: Binding): Binding | null { - const next: Binding = { ...binding } + const next: Binding = cloneBinding(binding) function unify(term: Term, value: PropertyValue): boolean { if (term.kind === 'var') { @@ -511,7 +523,7 @@ function evalPath(pattern: TriplePattern, binding: Binding, triples: Triple[]): for (const from of froms) { for (const to of [...reachFrom(from)].sort()) { if (ov !== null && to !== ov) continue // object bound → must equal - const b: Binding = { ...binding } + const b: Binding = cloneBinding(binding) if (sVar) b[sVar] = from if (oVar) b[oVar] = to results.push(b) @@ -539,12 +551,12 @@ function evalBGP(patterns: TriplePattern[], triples: Triple[], seed: Binding[]): function joinSolutions(a: Binding[], b: Binding[]): Binding[] { const out: Binding[] = [] - for (const x of a) for (const y of b) if (compatible(x, y)) out.push({ ...x, ...y }) + for (const x of a) for (const y of b) if (compatible(x, y)) out.push(mergeBindings(x, y)) return out } function evalGroup(group: GroupGraphPattern, triples: Triple[]): Binding[] { - let solutions = evalBGP(group.patterns, triples, [{}]) + let solutions = evalBGP(group.patterns, triples, [emptyBinding()]) // Lone nested { … } subgroups → natural join. for (const sub of group.subgroups) solutions = joinSolutions(solutions, evalGroup(sub, triples)) @@ -558,7 +570,7 @@ function evalGroup(group: GroupGraphPattern, triples: Triple[]): Binding[] { // VALUES: inline data → join. for (const vb of group.values) { const rows: Binding[] = vb.rows.map((row) => { - const b: Binding = {} + const b: Binding = emptyBinding() vb.vars.forEach((v, i) => { if (row[i] !== null && row[i] !== undefined) b[v] = row[i] as PropertyValue }) return b }) @@ -567,7 +579,11 @@ function evalGroup(group: GroupGraphPattern, triples: Triple[]): Binding[] { // BIND: compute a new variable per solution. for (const bind of group.binds) { - solutions = solutions.map((sol) => ({ ...sol, [bind.var]: evalBind(bind.expr, sol) ?? null } as Binding)) + solutions = solutions.map((sol) => { + const next = cloneBinding(sol) + next[bind.var] = evalBind(bind.expr, sol) ?? null + return next + }) } // OPTIONAL → left join @@ -576,7 +592,7 @@ function evalGroup(group: GroupGraphPattern, triples: Triple[]): Binding[] { for (const sol of solutions) { const matches = evalGroup(opt, triples).filter((m) => compatible(sol, m)) if (matches.length === 0) next.push(sol) - else for (const m of matches) next.push({ ...sol, ...m }) + else for (const m of matches) next.push(mergeBindings(sol, m)) } solutions = next } @@ -624,10 +640,13 @@ function aggregate(solutions: Binding[], groupBy: string[], aggregates: AggSpec[ const variables = [...groupBy, ...aggregates.map((a) => a.as)] const bindings: Binding[] = [] for (const rows of groups.values()) { - const out: Binding = {} - for (const g of groupBy) out[g] = rows[0]?.[g] ?? null - for (const a of aggregates) out[a.as] = computeAgg(a, rows) - bindings.push(out) + // Keys here are GROUP BY variables and aggregate aliases — both query-derived. Collect into + // an entry list and materialize via toResultRow so a name like `__proto__` is stored as an + // own data property instead of being swallowed by the inherited setter. + const out: [string, PropertyValue][] = [] + for (const g of groupBy) out.push([g, rows[0]?.[g] ?? null]) + for (const a of aggregates) out.push([a.as, computeAgg(a, rows)]) + bindings.push(toResultRow(out)) } return { variables, bindings } } @@ -770,13 +789,10 @@ export function runSparql(store: HellGraphStore, queryText: string): SparqlResul ? Array.from(new Set(solutions.flatMap((s) => Object.keys(s)))) : query.projection - let bindings = solutions.map((s) => { - // Build via a Map so projection variable names (query-derived) can't inject - // properties, then materialize a plain Binding (js/remote-property-injection). - const m = new Map() - for (const v of variables) m.set(v, s[v] ?? null) - return Object.fromEntries(m) as Binding - }) + // Projection variable names are query-derived. `s` is null-prototype, so `s[v]` can only ever + // return a value this query actually bound — never an inherited Object.prototype member — and + // toResultRow writes even a hostile name as an own data property. + let bindings = solutions.map((s) => toResultRow(variables.map((v) => [v, s[v] ?? null] as const))) // DISTINCT if (query.distinct) { diff --git a/ts/src/super-peer.ts b/ts/src/super-peer.ts index 2ebe472..8f936ea 100644 --- a/ts/src/super-peer.ts +++ b/ts/src/super-peer.ts @@ -36,6 +36,7 @@ import type { AuditSink } from './policy.js' import { Metrics } from './metrics.js' import { RateLimiter } from './rate-limit.js' import { loadOptionalDep as loadDep } from './optional-dep.js' +import { sanitizeLogValue } from './log-safe.js' /** Per-route scope requirement (enforced only when an auth verifier is configured). */ const ROUTE_SCOPE: Record = { @@ -312,9 +313,11 @@ export class SuperPeer { send(404, { error: 'not found' }) } catch (err) { this.metrics?.inc('hellgraph_errors_total') - // Don't leak internal error/stack detail to the client (js/stack-trace-exposure); - // log a newline-stripped, bounded detail server-side (no CR/LF → no log-injection). - const detail = (err instanceof Error ? err.message : String(err)).replace(/[\r\n\t]+/g, ' ').slice(0, 500) + // Don't leak internal error/stack detail to the client (js/stack-trace-exposure); log a + // bounded, single-line detail server-side. The message is attacker-shaped — a SPARQL parse + // error embeds the offending token verbatim — so it goes through sanitizeLogValue, which + // neutralizes every line terminator and control sequence, not just CR/LF (js/log-injection). + const detail = sanitizeLogValue(err instanceof Error ? err.message : err) console.error('[super-peer] request error:', detail) send(500, { error: 'internal error' }) } diff --git a/ts/src/superpeer-service.ts b/ts/src/superpeer-service.ts index 60846bf..7f00e0f 100644 --- a/ts/src/superpeer-service.ts +++ b/ts/src/superpeer-service.ts @@ -19,6 +19,7 @@ import { HmacTokenVerifier } from './auth.js' import { Metrics } from './metrics.js' import { RateLimiter } from './rate-limit.js' import { InMemoryAuditLog } from './policy.js' +import { sanitizeLogValue } from './log-safe.js' export interface SuperPeerServiceEnv { HELLGRAPH_STORAGE_DIR?: string @@ -78,7 +79,9 @@ export async function startSuperPeerFromEnv(env: SuperPeerServiceEnv = process.e } catch (e) { // Hyperswarm is an optional dependency; without it the node still serves + replicates // over any transport wired directly (e.g. a sidecar). Don't crash the pod. - console.warn(`[superpeer] joinSwarm skipped: ${(e as Error).message}`) + // Same log boundary as super-peer's request handler: this message originates outside the + // process (swarm/DHT peers), so it is untrusted text and must not be able to forge a line. + console.warn(`[superpeer] joinSwarm skipped: ${sanitizeLogValue((e as Error).message)}`) } } From 35621dc9fb4d48199ba470431967055ae9c3d81a Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:37:49 -0400 Subject: [PATCH 2/7] log-safe: cover \p{Cf}, and stop deriving the test's bar from the implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, and the second is why the first survived review. 1. `sanitizeLogValue` escaped \p{Cc}, \p{Zl} and \p{Zp} — correct for NUL, ESC, VT, FF, NEL, LS, PS, DEL and CRLF, all re-verified. It did NOT cover \p{Cf}. U+202E RIGHT-TO-LEFT OVERRIDE, the rest of U+202A-U+202E, the U+2066-U+2069 isolates, U+200B ZWSP, U+200C/D, U+200E/F, U+00AD, U+061C and U+FEFF all passed through unescaped. These are the Trojan Source class. They contain no terminator, so nothing about the line BREAKS — they change what a human reads. `admitted=` + RLO + `resu` renders as `admitted=user` while the bytes say otherwise, which means an auditor reading a receipt and a tool grepping it disagree about its content. The function's stated guarantee is that nothing can "rewrite a log line", and in an estate where log records are read back as evidence that covers the reader, not just the parser. Added \p{Cf} to the escaped class. 2. The test's general assertion was const LINE_BREAKING = /[\p{Cc}\p{Zl}\p{Zp}]/u character-for-character the class the implementation strips. It therefore could not fail for anything the implementation omitted — it was decoration that looked like coverage, and it is precisely why (1) got through. Replaced with UNSAFE_IN_A_LOG_FIELD: an explicit range enumeration derived from what a reader, terminal or line-oriented parser can be made to misrender, with each range commented for the behaviour that puts it there. It is deliberately NOT expressed in the implementation's categories, so if the implementation later drops a class this list still names, the tests go red. Added eleven reader-forgery vectors to FORGERY_VECTORS. Teeth proven both ways: - widened tests vs the PRE-FIX implementation: 2 of 12 FAIL, first failure "RLO U+202E (Trojan Source): a line-breaking/control character survived sanitization". The end-to-end SPARQL-parse-error test fails too, so the vectors reach the real log boundary, not just the unit. - with \p{Cf} added: 12/12 pass, full suite 422/422. - re-checked that the parse-error test is not vacuous: all 20 vectors throw and reach their assertions; none hit the `continue`. --- ts/dist/index.d.mts | Bin 228016 -> 227372 bytes ts/dist/index.d.ts | Bin 228016 -> 227372 bytes ts/dist/index.js | Bin 428137 -> 426186 bytes ts/dist/index.mjs | Bin 419169 -> 417301 bytes ts/src/log-safe.ts | 35 +++++++++++++++++----- ts/src/security-hardening7.test.ts | 46 +++++++++++++++++++++++++++-- 6 files changed, 72 insertions(+), 9 deletions(-) diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index 4ce491f966e53338534076c7aa0669ca28d68e09..5b82562cb53c77e0c497e3517ba1f231ba055d63 100644 GIT binary patch delta 1826 zcmYjSO>ZMb5Y;%WU_pBVBo2EhAwIGj+gLdS5wsFcV(&)Em*S1kBBV@vrtN7m(`~wY z?3fRECH?>hB>xGJ5CWOy#E;;}i7OW_aM^%()sw_)S(ay}`c>7dS8u-m`_{LA-Fp0E z^wrbZmX+d6=^^<{Qm(WlPWii+gCzCqh%tlol-}xp1yYu}BN$y-e@Lv=)x7QEa6v zV>F}Dj3eQmfo?ZKhMgt!Yza|1X=AXVP$FmSPd$imVBeWSR?NxV$xALtl&M1XOZbhA z0Kf6x5}ql&9evNZj|$>>vrNWaST(~B;$#38ZE~dFlLuzvf^bHIB#`m?|7e) z*ITVFh};K%5Ez0hVH83!on!{##z4tDy=uJGYVT0*u=|i6JUV*NISAcfSB7TKB$UME zfjLn~l7oRvJSKyFYQ3ZR7f+)n&(#4pzjqP6^+7$bbS<`3u6as*m8!srHF=$?d!I>b z8WWZJj8BKkwhs5_{flUA{`Ez4cZD&3xrmzcKQE%!XB^X}M0hzgKoogyCXHM<@a@9k zkx|-jGEumZWX5sf29F?TvD7-&vu{$CxhaR4XHryPGchPtrch3R+um#UdW_bWxzR(c znm5qw)fP=a0JD2tkg;lrmB_I{g>nu+ymcG`A^MOZRcSB>oWWEBbRr;Jj#3tsLc?Ae z4b#A4Tiy*#18WKd%+OTe-^t1ka6B<(9uS$Eflzr+$}>j6XCzvz)~rcAXp}30Lj|#p zCB-KPy>_>Ax_fZiKHWRKf7;#c?Cr!5Cxbefl&E9c$Yuh9(%6P~o7_*+yG=lWS}TY0 zoGDLbuKLnqK%f9}Q)6%tst+C|=(y9~=^TSG4Ne)ofMbDG=Brxx`lZH~r?9w=2Bl73 z4a8((G?JP}LC#w>THO3MYDF``f_RH4B*(0C>?)gar9}AS_tKh#7tfAfv#mHE;0(Tzp)Q|+JHEXE+>@WObX0%PSK;>zJ;DoShz%py61*tjxzjzvxsyZq%_BY|My>|kOQZw`vbFkWLD zLn}rZW*k@BY~`mnOH5s+Z6D9Y?b>Veq947m`1R*n@5$nAyV8gj%B`f);&;FD+l~JL Dlmu*{ delta 2382 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 Dlmu*{ delta 2382 zcmZuz&ud&&7-c3FOG*(}Rac*>*h!d7K^MXpsv%8iut{r^svy#L=e;|5w|Vd0@!tC~ zow}HU;6G4hc7nK7&>{{l1aa@ejo`L_K{vWku%7SUnaMPCH*4yaICj^BNze&*ZZzO5wnbE&a)Bz0!2%qTV5TakJy#eg!I z7Q#wOy2h5mw`om_QeH~v;l0mEwPY(^PmV;PGNfBS5K23raJ{PR6|$7tJ5s)d6<%DY zt(&_W``g!c$qbLArDW^cjh*%VTN_t4H{RJxnxu^t{nA+ z<48$7!Sy;%+dJ24p@U(Zyg-`ZedR(g&B2K!wmOb-L_p@Wl_%X0Vsft%Q&B6qccFbj>!f*3E>$Au3Fv! zT{z2xE!k?NXLk-n4~KBFG+9;fVKj$(hSFhSoNR)gBF#kydtGZvLeCD6gvjv@bB7um z2GapItUOY(Pe#-E*WYM@(*0FXpvx$x5+Dh!Wzkxub>IQqPVkHGN$_q!skm7bR7#;k z7o1}r{uFVLtpkBf26SQ|+<6>{XDl+=g|I;N`@cVY;U(p^r|g&6R?to(f`0$Yx6chZ z**@;iBm!_J5=)2`=0MXhODkOtb_FFkEqX3Db6U~>LRbr-_8_HHprQ5M%Z%e)soRtQ z+8_Xm?EmniKy^8^g6E8?r?d_uECxu*r3H1&!~qHf)^NjJE9Kq5|2{WeibJ$4sI0tT zg(9SvWzSO=1jlHq3^5x@hx}|QCX>xYN>*SSD$OU8m@p%kFdF5Si5&!mlj)L!Ahkyj zS3w#wD1p;Jti3^?2se%1QJluG3Wec18vXJ~{l(!Nx^V=qvD{|5J&DPiix*ddHyzs{ z`&lIua_Gu+6(XKAKq%nKjcf1kv8IkS2gc+~x{!Goy^ozz1TF@Ob%Lff%}FE@10X{6 zn#oBYS4wi%;AS6xmzY{h6Z@Np&S*)QLGoBYn7xC`bCN(vy#u_WuSG+@;P@=Nr)Pu(1Zs5`iXm9OcVv+n7 zSUf>o{}A|Ka;)MR6M}uR&4np>b_4Dim!v-MIa7}RPZRb{RWM8uR3Lv)3Dm*t4hcDp zBh%M(+jM3k3FM)2$n%U08c^~SN5H!gWx$k-!h7;D6uw=aA&i=kCtH^1I1ISE!KnaZo8Kz6y@r%0WGM4?Z7wJ=b(uRt&1z&4 VS9L4F@TX5*yZFkl7PoOj-4~OqmLUKD delta 2126 zcma)6&u<%55SDANL6K0aMrt7ubg~M~u4->=Ck=^%6Qcf5O%z+L(*t!(ys_WL+t|D5 zzO_}?SuI7axY7e!Bu;?1A-Gi9TM<`IJ#g%yC=wD965>B#-tPLxIz{T~y`7ym^UXKk z%x@2;{`zw2vqzCfr=CXd9Dh+gj=I(3*o&_m_aRrZJF)>+H$Ext<_o)9#lrRt$U%7_ zoq)`80@%5b8aq$eDytJpTdSF>u0dVy(!92%Yi2{}64^*Vi`_-&O#rA02;L{;ayiI| zU=Z7E8yYZAcpnx$dkQXI>|4^Q6wIJ+3XYEYwnenD_bFJ1%nD=?=_&>{h!-kUOWDts zgfwEbhA35;MQ&2U9DFz)H907>TYJP1 zTIF;_3^+xc18as^k^52GLS>kT^#DGEG^5eyWDU}+0rpr@hKj`reA#vFf17#|GkZP& zwOA4Hab|%f9T+cr#>*?g+Y1^D3JyJ9^8A*ZKfaDku6taSKd5+G=;&%4n~x$Hrdobg zs+E2!=jIR5nKMl3*NeBdHnw-K+_-jS)4AE@-gaJo6N}kpq`S=5Q4mQJ1;Hnv2ek&N z9b{oB75tF^a{rLjOj6GRFKdu56_d+LsWdXwe+pJ5;YDj3YBN+II?Te>@Zw>`<8E z-d!wFQVc>K5TPtdQaJDd=~6+gNK`jWK}4J*gN4YDHyXm2fR5a3lhGMdP^Tk7VY4Ld zlR-`W=|C?W4%TjG_ovvDGjDNk*?!$HH{0`X)*QA7G~EPsSQzYeSyGj?Wn*7f*<1@F z|FbaL8~OC%Vo}mvUDX6^8!uX77262l&5(sxpX9TkNS-nTzbLHUS?zMax^F({r>3E5`#hVCnOF6<hY>+zR!)y??QcOdxnXuPJz9FlL8cr{|g@Cy~-iyC?`acZ5sr&!{ diff --git a/ts/dist/index.mjs b/ts/dist/index.mjs index e3e2633ba0063dbf7487a3c95ec662e96f456846..6058b4bce7e483e5f363ffa1f7bec2059a9e5499 100644 GIT binary patch delta 435 zcmY+Au}T9$5Qd4#9-KReabbBS(-- zCLvOzZUm;MVbWz*f%%mhCwtTtFW1N%!5-jl5B7F4c_Sf*ZqS3=uVV_C-R<0(m0ize zGh6GBgxIm|G!~nt(uJ;Of9rUU>UPw2W{nm7;OubLJnESkQ?Z12TGf_D{~1R)VO$qs zOEbjq1?m^MFBBFVmbQEjIxiC_`W~ma$Y&V*y&w(_=EUS~m4M5@uW{x&1b$dn#*{cv z#}!vGJ_S}QFv07%Q6qe(x2|17^G(ZTT7t2j|RcC8&eyE)~k(D;8Tg66Af&j-P8+#mYB1}OnrVTG1YG@)-zYU_Y+;J6Qg2=J{*z}` zelfWrZj`PFi}>}y0iVAzRS?eP2$8EI*(XIjj^TC4fB`m%R;yO&JI8_7HXI^SkgciP z#NgF@ydZcCBC2jwVo_xJbZolN>&h61>kwz8xeP2oT%y0KfmZ}p=54Xhh5uE|NuTVV zIa3fYS(;`&yTMr}IGb>Chr8i9Cp;H(!t-kW?Sdm>T~|t|8mh~8Q?H%ZHsQ4uj?~N; z{r*XCV3^r-Id^v@y|%e@>*ms()g$?*@!34`m=Bjw$SDhj3`d~}#R@6zCZQ)WZbX6X z?~$TKN=XRQzY?L*Rv7F;)G4nSioY&+xKuJv1}0Xfbe-y6nzAV zyQF~?1<}+Ei>LA5f)|H~=|e@oApI}L%V;BieI)N%)d%aNx0 zWnV_lLDMZzdca}30d={mFp1JTvcfJHe`-Gok>;si7agJ&@9B!hqby;;7Pe3(0LR`o ze!47P3uS5U*4n-|KVO}u?AaO^?njeM5D^!(GqFiUd?4i zx06zC@O8lk*}`#U%VtECNUN_PL7Avf|mni=0{_o=YAylQ4Tn+~_Q(RS{Tqiwp7 z2+fV-vIzG(>+)gcbr@?fc2l;`osNX)xPgt%sz*@H1~l`{}!( PN?@?py?y max ? escaped.slice(0, max) + '[truncated]' : escaped } diff --git a/ts/src/security-hardening7.test.ts b/ts/src/security-hardening7.test.ts index 9c40098..f7d6f45 100644 --- a/ts/src/security-hardening7.test.ts +++ b/ts/src/security-hardening7.test.ts @@ -155,8 +155,37 @@ test('SECURITY: an unsupplied Cypher $param named for a prototype member resolve // them author entries a later reader attributes to the system. const C = String.fromCharCode -/** Every character class a terminal, log viewer or line-oriented parser may treat as a break. */ -const LINE_BREAKING = /[\p{Cc}\p{Zl}\p{Zp}]/u + +/** + * What a log READER can be made to misrender. + * + * Written as an explicit enumeration, deliberately NOT as the Unicode categories + * `sanitizeLogValue` happens to strip. The first version of this constant was + * `/[\p{Cc}\p{Zl}\p{Zp}]/u` — character-for-character the implementation's own class — so the + * general assertions below could not fail for anything the implementation had omitted. They + * were decoration that looked like coverage, and `\p{Cf}` survived behind them: U+202E and + * U+200B passed the whole suite. + * + * So this list is derived from the consumer's behaviour instead. Each range is here because of + * what it does to a reader or a parser, not because of which category it belongs to. If the + * implementation later drops a class this list still names, these tests go red — which is the + * only arrangement in which they are worth running. + */ +const UNSAFE_IN_A_LOG_FIELD = new RegExp( + '[' + + '\\u0000-\\u001F' + // C0: NUL truncates C-string consumers, ESC drives ANSI, VT/FF/CR/LF break + '\\u007F-\\u009F' + // DEL + C1 controls; U+0085 NEL is a real terminator to Unicode readers + '\\u00AD' + // SOFT HYPHEN — invisible; splits a token so a search never matches it + '\\u061C' + // ARABIC LETTER MARK — bidi control + '\\u200B-\\u200F' + // ZWSP/ZWNJ/ZWJ/LRM/RLM — invisible, or reorder the rest of the field + '\\u2028\\u2029' + // LINE / PARAGRAPH SEPARATOR + '\\u202A-\\u202E' + // bidi embed/override — the Trojan Source class + '\\u2066-\\u2069' + // bidi isolates + '\\uFEFF' + // BOM / ZWNBSP — invisible + ']', 'u') + +/** Kept under the old name so the assertions below read as before. */ +const LINE_BREAKING = UNSAFE_IN_A_LOG_FIELD const FORGERY_VECTORS: [string, string][] = [ ['LF', C(0x0a)], @@ -169,6 +198,19 @@ const FORGERY_VECTORS: [string, string][] = [ ['PS U+2029', C(0x2029)], ['NUL', C(0x00)], ['ANSI CSI erase-line', C(0x1b) + '[2K' + C(0x1b) + '[1G'], + // Forge the READING of the line rather than the line itself. None of these contain a + // terminator, so every CR/LF-shaped defence lets them straight through. + ['RLO U+202E (Trojan Source)', C(0x202e)], + ['LRO U+202D', C(0x202d)], + ['RLE U+202B', C(0x202b)], + ['bidi isolate U+2066', C(0x2066)], + ['bidi isolate pop U+2069', C(0x2069)], + ['RLM U+200F', C(0x200f)], + ['ZWSP U+200B', C(0x200b)], + ['ZWNJ U+200C', C(0x200c)], + ['SOFT HYPHEN U+00AD', C(0xad)], + ['BOM U+FEFF', C(0xfeff)], + ['ARABIC LETTER MARK U+061C', C(0x61c)], ] test('SECURITY: no control character can forge a second log line', () => { From 61bb2894027c22bc82a072ea6df9bca0c40254c5 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:43:17 -0400 Subject: [PATCH 3/7] log-safe: escape astral format characters as themselves, not as a surrogate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on from the \p{Cf} widening. \p{Cf} extends past the BMP (U+13430-U+1343F Egyptian format controls, U+1BCA0-U+1BCA3) and the u-flagged class matches those as a whole code point, so `charCodeAt(0)` reported only the HIGH SURROGATE: U+13430 printed as `\ud80d`. Not a security hole — the replace consumes the whole match, so the character is neutralized either way, verified. But these are escaped rather than deleted specifically so a reader can see WHAT was neutralized, and naming a code point that was never in the input defeats that. `codePointAt` + `\u{...}` above U+FFFF. Test added; verified it fails against the previous implementation with "U+13430 must be escaped as its own code point, got \"benign\\ud80dtail\"". --- ts/dist/index.js | Bin 426186 -> 426240 bytes ts/dist/index.mjs | Bin 417301 -> 417355 bytes ts/src/log-safe.ts | 16 +++++++++++++--- ts/src/security-hardening7.test.ts | 13 +++++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/ts/dist/index.js b/ts/dist/index.js index c568b32de036f70ef2cb69fdbda384ebae2ade74..d9e24d337a4dba5620e77f7c021e6609cad6f600 100644 GIT binary patch delta 122 zcmX@rDAmv;)zHG&!nB3?=|iFXl+=Ly%)AoE5)A`Q1$%o1gXte1GOLPbrYUG710@w~ zY!!@5O*IvYQcFsU@}_G&VwRmQ^oUuQ-OSX~*i^xOx+hRXRKZF?DJG`0T1i1$AtSW{ WNGjDTwWmE|24a@&X^&V_<^cfOVJJ`l delta 104 zcmZo@k~-BW)zHG&!nB3?=|i53#3JYXlvKwO4Fk>T-ybro8W*LOlosVFBm+eiY-|;b zOidN+6_jFPDwGto6*5vQ^a>JFf=d#MN;Hgg6qF2 zW2<0fYO1MFlv+|+l&7E+6H}q2pskRRTA^2vm=au)SX826q@$o@prmQdr2ta~(`sgF rYHX@t57JhOq0I!MO~DE*T@9578m?5!sMPL%j~R$rw)@{>Jys6@N&7h@ delta 105 zcmX@TM{?>O$%Yoj7N#xC=kD@kBo;a6r=&WTXc%Zte{h#sRir4jq_ikcAsHyDU}LLb zWNNBlKV9Y?v#h*=m4Z@COsSHBwn9c~g { + // \p{Cf} extends past the BMP. The `u`-flagged class matches the whole code point, so the + // character is neutralized regardless — but the escape is the forensic record of WHAT was + // neutralized, and charCodeAt would name the high surrogate (\ud80d) instead of U+13430. + for (const cp of [0x13430, 0x1bca0]) { + const out = sanitizeLogValue('benign' + String.fromCodePoint(cp) + 'tail') + assert.ok(!LINE_BREAKING.test(out), `U+${cp.toString(16)} survived sanitization`) + assert.ok(out.includes(`\\u{${cp.toString(16)}}`), + `U+${cp.toString(16)} must be escaped as its own code point, got ${JSON.stringify(out)}`) + assert.ok(!/\\ud8[0-9a-f]{2}/.test(out), 'must not report a bare surrogate') + } +}) + test('SECURITY: sanitizing leaves ordinary log text untouched', () => { const ordinary = "SPARQL parse error: expected 'BY', got 'LIMIT' (offset 42) — 100% ok" assert.equal(sanitizeLogValue(ordinary), ordinary, 'the sanitizer must not mangle normal messages') From 8639100e2660c03bccb43cdb80c1f34eb52bcd68 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:20:18 -0400 Subject: [PATCH 4/7] log-safe: the sanitizer must not be able to throw (Copilot review, #34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepted from Copilot's review. `String(value)` is not total, and this PR is what makes the sharp case live. String(Object.create(null)) -> TypeError: Cannot convert object to primitive String({toString(){throw}}) -> propagates Verified both. The null-prototype case matters here specifically because safe-dict.ts, added by this same PR, hands out `Object.create(null)` dictionaries throughout the engine — so one reaching a log boundary is not hypothetical. And super-peer calls sanitizeLogValue from INSIDE its catch block, so a throw there would abandon the 500 response and make the sanitizer the outage. The documented guarantee is "for any input whatsoever", which has to include inputs that refuse to stringify. Added `coerceToString`: String() under try/catch, falling back to `[unstringifiable ]` via Object.prototype.toString.call, which reads the internal class without invoking user code. Outer guard covers exotic proxies. Guarantee list now states explicitly that it returns rather than throws. Test asserts the precondition first (String() really does throw for each of the three values) so it cannot quietly stop exercising anything, then asserts the sanitizer returns a safe single-line field for each. Verified it fails against the previous implementation with "Error: boom". --- ts/dist/index.d.mts | Bin 227372 -> 227453 bytes ts/dist/index.d.ts | Bin 227372 -> 227453 bytes ts/dist/index.js | Bin 426240 -> 426483 bytes ts/dist/index.mjs | Bin 417355 -> 417598 bytes ts/src/log-safe.ts | 33 ++++++++++++++++++++++++++++- ts/src/security-hardening7.test.ts | 28 ++++++++++++++++++++++++ 6 files changed, 60 insertions(+), 1 deletion(-) diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index 5b82562cb53c77e0c497e3517ba1f231ba055d63..76730145057f0e0dfb7ac1fbcf2581a2ea4c0323 100644 GIT binary patch delta 107 zcmZ4Uf%oqR-i8*&ElkT;{1g-vbQLm76pB(yN{jM}twHR()Uwnfg_4Y-{PJQQh4PHV z60iVJCbL)}BQY-}HAO+AI5kxvAvr&_C^b=9ub?QuB)_DxAXTpfYO`K)Vona!`r6op>Gk)SWfhdrO^8*p2D(f^p%zHi ca@9`X@rc=-1LlQx<;ToG%(7kiG3(vA0LRc)u>b%7 delta 51 zcmey|EY;8?)zHG&!nB3?(4*-WA2ZudfAEM|l3T%Ep)4_{G*!V$p?=cg7Wr-tMQmlS2@rE8QW=9H#tDpXJ3UC*jC{q1dL zb{3G(^o8P#Vm!!dCNs_y_Ae=_RH)`s0E42`lG36)g@owRykf9fnQ57cNja$sD%Ji; zS*gh-dId%KCHW*?!GvxrS! YeUI6e1Lo>>p8L!|%(9*5KC68_0Lg4pJ^%m! delta 71 zcmdnDPxAC0$%Yoj7N#xCtM5(Ub)VT*rKGYTH9t+EEHS4vRl(NQRzaz_q$o2lT}i { + const throwingToString = { toString() { throw new Error('boom') } } + const nullProto = Object.create(null) + const nullProtoWithProps = Object.assign(Object.create(null), { variable: '?__proto__' }) + + // Precondition: these really do defeat a bare String(). If this ever stops being true the + // test below is no longer exercising anything. + for (const v of [throwingToString, nullProto, nullProtoWithProps]) { + assert.throws(() => String(v), 'precondition: String() must throw for this value') + } + + for (const v of [throwingToString, nullProto, nullProtoWithProps]) { + const out = sanitizeLogValue(v) + assert.equal(typeof out, 'string') + assert.ok(out.length > 0, 'must produce something a reader can see') + assert.ok(!LINE_BREAKING.test(out), 'and it must still be a safe single-line field') + } + + // The exact shape super-peer's catch block passes when a non-Error is thrown. + assert.doesNotThrow(() => sanitizeLogValue(nullProto)) +}) + test('SECURITY: an astral-plane format character is escaped as itself, not as its surrogate', () => { // \p{Cf} extends past the BMP. The `u`-flagged class matches the whole code point, so the // character is neutralized regardless — but the escape is the forensic record of WHAT was From 7affa90dd5c1bf067da10c0fd76208ea35a0278e Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:40:36 -0400 Subject: [PATCH 5/7] superpeer-service: log the thrown VALUE, not `.message` off a bad cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #34 (low-confidence channel). `(e as Error).message` reads `.message` off whatever was thrown; a rejection need not be an Error, so a thrown string or a plain object logged `undefined` and every non-Error swarm failure produced the same contentless line. This PR is what makes handing the raw value over the SAFE choice: `sanitizeLogValue` now coerces total via `coerceToString`, so it cannot throw on a null-prototype object or a hostile `toString`. super-peer's request handler already passes the value this way two files over — this is the same shape, applied consistently. --- ts/dist/index.js | Bin 426483 -> 426508 bytes ts/dist/index.mjs | Bin 417598 -> 417623 bytes ts/src/security-hardening7.test.ts | 29 +++++++++++++++++++++++++++++ ts/src/superpeer-service.ts | 8 +++++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/ts/dist/index.js b/ts/dist/index.js index e449daa3dece44d41e616a5722a6583a54a9e787..0872bd2e1ab559bbfe8c846b0dd50451eaf82d92 100644 GIT binary patch delta 57 zcmey|EY;H@)zHG&!qmdNg~ilox?vTwD8F8AYH@L5da8nzLaJtM!t{@h%%bg~J}f}Y Lx;@l~ZJsFrGT9Q+ delta 48 zcmeBak^0;$)zHG&!qmdNg~il|TQ4`YxHvIARkJo>`bS4*(e^wa79eKbp6A0h&lCWs Cb`anI diff --git a/ts/dist/index.mjs b/ts/dist/index.mjs index f0c998568854ed05a9e41817728f89aefaf89111..ad68d6d5331e67630b0496954ed2e761658b0ccd 100644 GIT binary patch delta 56 zcmdnDPxAUc$%Yoj7N!>FB`hA(^UIh;`So&Bi;EM}Qx&WfQZ;K6rt8}=i?%CxumCaZ Kb_EYMIdK3nHxWJn delta 38 scmcbFB`hB7dbz2^#fj sanitizeLogValue(nullProto)) }) +/** + * Copilot review finding on #34 (low-confidence channel). A `catch` binding is not an Error — + * `throw 'boom'` and a promise rejected with a non-Error both land here — so `(e as Error).message` + * is `undefined`, and every such failure logs the same contentless line. The whole reason the raw + * value can be handed over instead is `coerceToString`: the sanitizer is total, so passing the + * value is the SAFE option, not the risky one. + */ +test('a non-Error throw keeps its diagnostic content at the log boundary', () => { + const notErrors: unknown[] = ['swarm refused: ECONNREFUSED', 42, { code: 'EAI_AGAIN' }, null] + + for (const e of notErrors) { + // What the bare cast produced: the content is gone. + const viaCast = sanitizeLogValue((e as Error | undefined)?.message) + assert.equal(viaCast, 'undefined', 'precondition: reading .message off a non-Error loses it') + + // What the call site does now: the value itself, coerced safely. + const viaValue = sanitizeLogValue(e instanceof Error ? e.message : e) + assert.notEqual(viaValue, 'undefined', `${String(e)}: must not collapse to "undefined"`) + assert.ok(viaValue.length > 0) + } + + // A real Error still logs its message, not its [object Error] tag. + assert.equal(sanitizeLogValue(new Error('real') instanceof Error ? new Error('real').message : ''), 'real') + + // And the non-Error path is still sanitized — it is untrusted text like any other. + const forged = sanitizeLogValue('swarm' + C(0x0a) + '[superpeer] audit: admitted=attacker') + assert.ok(!LINE_BREAKING.test(forged), 'a non-Error throw must not be able to forge a line either') +}) + test('SECURITY: an astral-plane format character is escaped as itself, not as its surrogate', () => { // \p{Cf} extends past the BMP. The `u`-flagged class matches the whole code point, so the // character is neutralized regardless — but the escape is the forensic record of WHAT was diff --git a/ts/src/superpeer-service.ts b/ts/src/superpeer-service.ts index 7f00e0f..a2cb64e 100644 --- a/ts/src/superpeer-service.ts +++ b/ts/src/superpeer-service.ts @@ -81,7 +81,13 @@ export async function startSuperPeerFromEnv(env: SuperPeerServiceEnv = process.e // over any transport wired directly (e.g. a sidecar). Don't crash the pod. // Same log boundary as super-peer's request handler: this message originates outside the // process (swarm/DHT peers), so it is untrusted text and must not be able to forge a line. - console.warn(`[superpeer] joinSwarm skipped: ${sanitizeLogValue((e as Error).message)}`) + // + // The VALUE is handed over, not `(e as Error).message`: a rejection need not be an Error, + // and that cast reads `.message` off whatever it is — `undefined` for a thrown string, so + // every non-Error failure logs the identical useless line. `sanitizeLogValue` coerces + // total (see `coerceToString`), which is what makes passing the raw value the safe option + // rather than the risky one. Same shape super-peer's catch block uses. + console.warn(`[superpeer] joinSwarm skipped: ${sanitizeLogValue(e instanceof Error ? e.message : e)}`) } } From 57979edc40f9b71ab608b304f4941111075b4719 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:53:30 -0400 Subject: [PATCH 6/7] patternMatcher: the OUTPUT row was still keyed on a plain object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found reviewing this PR's own diff — Copilot reported 11 of 15 changed files, and this was in one of the four it did not reach. `findMatches` was converted to null-prototype groundings, and `unifyTerm` to `cloneDict`, but the row handed back to callers was still built as const row: Record = {} for (const v of variables) row[v] = ... keyed by the same query-derived pattern variable names. `row['__proto__'] = v` hits Object.prototype's inherited SETTER, so the write is swallowed: the variable stays listed in `variables` and is absent from every row. Reproduced against the pre-fix code — `declaredInVariables=true ownProperty=false`, and the value reads back as Object.prototype rather than the atom label. That is the WRITE mode safe-dict.ts documents, at the boundary of the file whose interior this PR fixed. The interior was converted and the exit was not. Now built through `toPlainRow`, which uses CreateDataProperty, so a hostile name lands as a real own data property — the same treatment cypher.ts already gives its output rows. Test asserts every HOSTILE_KEYS name is an own data property of the row and reads back as a string; it is red against the old construction. --- ts/dist/index.js | Bin 426508 -> 426466 bytes ts/dist/index.mjs | Bin 417623 -> 417581 bytes ts/src/patternMatcher.ts | 19 +++++++++++-------- ts/src/security-hardening7.test.ts | 29 ++++++++++++++++++++++++++++- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/ts/dist/index.js b/ts/dist/index.js index 0872bd2e1ab559bbfe8c846b0dd50451eaf82d92..9e7901a54a38291a03ff36a9c8ef4f241c9efb96 100644 GIT binary patch delta 78 zcmeBak$TiDwc+^z_LBU7oW#t$>2{iol1$ZHlNAn{a}}kQlosVFM3?DIPCTg05o^t* epirx+**yPX`}~89K+FWh%s|Ytef~k#+yeloiyu<} delta 119 zcmaFVEY;H@wc+^z(P}OQAV|*7D=twe$}d;2Rj97Dp8l|xRg||Zu_!Y!DJQj9Q=yt` za-oj!WT}H{K;=MX(Pgm;wv)XMDo-zrU=-#8idX|BYBjB=2Z}I?H}@TE?>oo{#7scU O48$zk`wp^(8~_0LM=ON@ diff --git a/ts/dist/index.mjs b/ts/dist/index.mjs index ad68d6d5331e67630b0496954ed2e761658b0ccd..35a2faf0ba72531e259e72450c63ca6cda959192 100644 GIT binary patch delta 102 zcmcb4EBulFhpN+I9Ca0x=U1GXpWpcHMofS_=W< C$R%k2 delta 119 zcmZ3xPxAUc$qg}k#j3d!fFL { - const row: Record = {} - for (const v of variables) { + // Output rows are keyed by pattern variable names, which arrive from Cypher query text — so this + // is the same untrusted-key surface as the groundings above, at the boundary rather than inside + // it. Built through toPlainRow (CreateDataProperty) instead of `row[v] = …` on a plain object: + // assigning `__proto__` hits Object.prototype's inherited SETTER, the write is swallowed, and the + // variable stays listed in `variables` while being absent from every row — a declared column that + // silently is not there, which is the WRITE mode described in safe-dict.ts. + const results = groundings.map((g) => + toPlainRow(variables.map((v) => { const atom = g[v] ? as.getAtom(g[v]) : undefined - row[v] = atom ? (atom.name ?? atom.type) : '' - } - return row - }) + return [v, atom ? (atom.name ?? atom.type) : ''] as const + }))) return { variables, results, groundings, evaluatedAtSeq: as.logicalClock } } diff --git a/ts/src/security-hardening7.test.ts b/ts/src/security-hardening7.test.ts index cfb8378..8ed431f 100644 --- a/ts/src/security-hardening7.test.ts +++ b/ts/src/security-hardening7.test.ts @@ -7,10 +7,11 @@ import { join } from 'node:path' process.env['HELLGRAPH_STORE_DIR'] = mkdtempSync(join(tmpdir(), 'hg-sec7-')) import { getHellGraph } from './store.js' -import { getAtomSpace } from './atomspace.js' +import { getAtomSpace, AtomSpace } from './atomspace.js' import { runSparql } from './sparql.js' import { runGremlin } from './gremlin.js' import { runCypher } from './cypher.js' +import { findMatches, V, L } from './patternMatcher.js' import { sanitizeLogValue, LOG_FIELD_MAX } from './log-safe.js' const g = getHellGraph() @@ -149,6 +150,32 @@ test('SECURITY: an unsupplied Cypher $param named for a prototype member resolve assert.equal(supplied.rows.length, 1, 'a genuinely supplied parameter named "constructor" must still resolve') }) +// ─── Attack 17b: the pattern matcher's OUTPUT row ────────────────────────────────────────── +// The groundings inside findMatches were made null-prototype, but the row it hands back was +// still built as `const row: Record = {}` and keyed by the same query-derived +// variable names. `row['__proto__'] = v` hits Object.prototype's inherited SETTER, so the write +// is swallowed: the variable stays listed in `variables` and is absent from every row. A +// DECLARED column that silently is not there — the WRITE mode safe-dict.ts documents, left +// behind at the boundary of the very file that fixed the interior. + +test('SECURITY: a pattern variable named __proto__ appears as an own column in the result row', () => { + const space = new AtomSpace('sec7-patternmatcher', false) + space.addLink('ListLink', [space.addNode('ConceptNode', 'alice'), space.addNode('ConceptNode', 'bob')]) + + for (const name of HOSTILE_KEYS) { + const r = findMatches(space, { clauses: [L('ListLink', V(name), V('other'))] }) + + assert.ok(r.variables.includes(name), `${name}: precondition — the variable is declared`) + assert.equal(r.results.length, 1, `${name}: precondition — the pattern matches one grounding`) + + const row = r.results[0]! + assert.ok(isOwnData(row, name), + `${name}: declared in variables but not an own data property of the row — the write was swallowed`) + assert.equal(typeof (row as Record)[name], 'string', + `${name}: must read back as the atom label, not an inherited member`) + } +}) + // ─── Attack 18: log-entry forgery at the log boundary ────────────────────────────────────── // CodeQL js/log-injection, alert 28 (ts/src/super-peer.ts:318, MEDIUM). Receipts and audit // records in this estate are read back as evidence, so a log line an attacker can SHAPE lets From 3e386fe32697ed9212bc4f032c1103208c90c046 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:18:20 -0400 Subject: [PATCH 7/7] build: regenerate dist after rebase onto main --- ts/dist/index.d.mts | Bin 227453 -> 229625 bytes ts/dist/index.d.ts | Bin 227453 -> 229625 bytes ts/dist/index.js | Bin 426466 -> 428423 bytes ts/dist/index.mjs | Bin 417581 -> 419455 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index 76730145057f0e0dfb7ac1fbcf2581a2ea4c0323..9ce25c2a6d38c08d6d4aacd48961bed27afc9e64 100644 GIT binary patch delta 2280 zcmZuzO=~1o6eS(}AfhtNV${_&v&aWY59neb4x>#bVSBR zN>2?!jUc!cB4j5BZd|w+6oU)h`2$?KonPQea3Of^tLjcC%(CmfcR$WO=iL79?^pl* z;C%3x9gUQeND)7h8#qTo+S>l;&c^n}&i=-& z`%TEbHM+|xu_mkb*xzuz(B|MKGANT!G<6gaf-1zxhdSd`uXC38^+1QLYvfa=qBL?q zXcRDo2bn$sFo=zq&Qi4aoEk_un##V^RY|hAz`4@d!cstKq!Tu1QnoJm(JCk~ z5Ex?yNXi(_x=Yjp9>DDa!Q>r-cooGe%P41MLKBi=8va;aH$ufeG|^6IbbcVxc{36( zSR_2qhC%h?KcBtvmUdfX_H(isw6oZNe*EnRS8B<&kM|Ra0Nhq$5sAVaXc}f&uuH+N zpaf^s(4}@tO9nuwqJU6`&{F8Aw{uH?e9UHzB48T?Kn?pp{VZ^u>q5z(QT3SCVT4(Q zq*B^>b$|kaHPY~)V169S~<)v4mP=xfH4?PV)aEzu*5OYzRkUz$KoAAmU zMa!@a9jEOiT4tmSqtR}O#6eIv*#S8y(s%@MCA7go36chC9ae#S(lmNUaR$T6G=}Tw z>C6Ge&q|KSp({7ghe$pE#uoTnrrR2TdEAl1M`gfC$+eAtya7HA~kZ=LkRNgU;N&h`yrHe=nehRbgXX2qa9Kud`ww8Z@^Pf0kg?4i zzx~~nt9T5`yUl4rueIb_X}nSRefq=G4$lOZx3~q~1+_Fb@^SCZy^Y4~#Z5z z2qcU%#@~lT24I&9j~tUFF(#wzQh4%s#{`Oshn{@>Tj%A|KYr=#{rY|ZbZ4|jVdP5=M^ delta 207 zcmey_$oKaHZ$k^?7N)fux5w;YN@SdF*3Kk6-JhM2ZM)ZQrf*F338h7u2@3gnIhEE5 zi3-X2c_l@O$t4O!sX3LIdFen2g~Yu4l8n?Mg@m;HqTIxi1cltv;u3}AjMU_8h0GEz z1ucctih`WXy^`Vt zprI)W2}P-eT&1bSC8;TnB?+376&H$6&rM?F*v`41DR}4hf(m9`#_gLbnNt|It5!2# GVFdt%QcI}- diff --git a/ts/dist/index.d.ts b/ts/dist/index.d.ts index 76730145057f0e0dfb7ac1fbcf2581a2ea4c0323..9ce25c2a6d38c08d6d4aacd48961bed27afc9e64 100644 GIT binary patch delta 2280 zcmZuzO=~1o6eS(}AfhtNV${_&v&aWY59neb4x>#bVSBR zN>2?!jUc!cB4j5BZd|w+6oU)h`2$?KonPQea3Of^tLjcC%(CmfcR$WO=iL79?^pl* z;C%3x9gUQeND)7h8#qTo+S>l;&c^n}&i=-& z`%TEbHM+|xu_mkb*xzuz(B|MKGANT!G<6gaf-1zxhdSd`uXC38^+1QLYvfa=qBL?q zXcRDo2bn$sFo=zq&Qi4aoEk_un##V^RY|hAz`4@d!cstKq!Tu1QnoJm(JCk~ z5Ex?yNXi(_x=Yjp9>DDa!Q>r-cooGe%P41MLKBi=8va;aH$ufeG|^6IbbcVxc{36( zSR_2qhC%h?KcBtvmUdfX_H(isw6oZNe*EnRS8B<&kM|Ra0Nhq$5sAVaXc}f&uuH+N zpaf^s(4}@tO9nuwqJU6`&{F8Aw{uH?e9UHzB48T?Kn?pp{VZ^u>q5z(QT3SCVT4(Q zq*B^>b$|kaHPY~)V169S~<)v4mP=xfH4?PV)aEzu*5OYzRkUz$KoAAmU zMa!@a9jEOiT4tmSqtR}O#6eIv*#S8y(s%@MCA7go36chC9ae#S(lmNUaR$T6G=}Tw z>C6Ge&q|KSp({7ghe$pE#uoTnrrR2TdEAl1M`gfC$+eAtya7HA~kZ=LkRNgU;N&h`yrHe=nehRbgXX2qa9Kud`ww8Z@^Pf0kg?4i zzx~~nt9T5`yUl4rueIb_X}nSRefq=G4$lOZx3~q~1+_Fb@^SCZy^Y4~#Z5z z2qcU%#@~lT24I&9j~tUFF(#wzQh4%s#{`Oshn{@>Tj%A|KYr=#{rY|ZbZ4|jVdP5=M^ delta 207 zcmey_$oKaHZ$k^?7N)fux5w;YN@SdF*3Kk6-JhM2ZM)ZQrf*F338h7u2@3gnIhEE5 zi3-X2c_l@O$t4O!sX3LIdFen2g~Yu4l8n?Mg@m;HqTIxi1cltv;u3}AjMU_8h0GEz z1ucctih`WXy^`Vt zprI)W2}P-eT&1bSC8;TnB?+376&H$6&rM?F*v`41DR}4hf(m9`#_gLbnNt|It5!2# GVFdt%QcI}- diff --git a/ts/dist/index.js b/ts/dist/index.js index 9e7901a54a38291a03ff36a9c8ef4f241c9efb96..baf8e97b2d87db9a5a6a4dc682930df485a2e45a 100644 GIT binary patch delta 2091 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