feat(client): inbox mechanism, write-guard, SPARQL injection hardening

Polyfill capabilities landed for Festipod's T02 features (all generic,
zero-domain — the consumer injects the domain).

- inbox: implement the previously-stubbed namespace. post(target,{from?,
  payload,ts?}) deposits {from,payload,ts} as RDF via docs.sparqlUpdate (the
  real injected ng, never makeNg); read/materialize + watch emulate the curator
  in-lib (deposits read via docs.sparqlQuery). `from` optional = anonymity.
- write-guard: caps.hasWritePolicy() + ng-proxy.sparql_update rejects when the
  target doc is under a write policy and the current user lacks its write cap;
  passthrough otherwise (no regression). Read-cap registry unchanged.
- sparql.ts (new): escapeLiteral / escapeIri / assertNuri, exported from index.
  store-registry now escapes every literal and validates/encodes every IRI-
  position value — closes a SPARQL-injection hole where an untrusted username
  could inject triples into the shim (the account→doc-NURI trust root).

Tests: inbox, sparql (incl. injection), ng-proxy write-guard, isolation-active.
68 tests pass; tsc --noEmit rc=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 15:51:00 +02:00
parent 654cb90d99
commit d804a436d7
12 changed files with 924 additions and 42 deletions
+5
View File
@@ -83,6 +83,11 @@ export class CapRegistry {
return this.readers.size > 0 || this.publicDocs.size > 0; return this.readers.size > 0 || this.publicDocs.size > 0;
} }
/** No WRITE policy declared → the write guard stays inert (passthrough). */
hasWritePolicy(): boolean {
return this.writers.size > 0;
}
clear(): void { clear(): void {
this.readers.clear(); this.readers.clear();
this.writers.clear(); this.writers.clear();
+189 -24
View File
@@ -1,29 +1,194 @@
/** /**
* Inbox — client side (deposit only). The MATERIALIZATION of deposits is the * Inbox — the ONE deposit + materialization mechanism, reused for BOTH meeting-
* curator's job (a separate package, deferred — see repo README), never the client's. * point registration AND submission to the discovery index (see the discovery-
* model decision: same `inbox.post` API, same watcher). GENERIC by construction:
* this module knows no application domain (no meeting-point, no notification).
* The consumer supplies the inbox document NURI and interprets the `payload`.
*
* ── TARGET vs POLYFILL ────────────────────────────────────────────────────
* Target: `post` seals a reference into the inbox owner's native inbox
* (`ng.inbox_post_link(...)`), and a SEPARATE curator process (the deferred
* `@ng-eventually/service` package) MATERIALIZES the deposits into the owned
* document. Here, in the shared-wallet polyfill (everything is readable),
* both sides are emulated in-lib:
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
* DOCUMENT (in the shared wallet) via the `docs.sparqlUpdate` primitive;
* - `read` / `watch` play the CURATOR: they read the deposits back via
* `docs.sparqlQuery` and expose them. This in-client emulation is enough
* for the polyfill — at migration the real materialization moves to the
* separate curator and this read side goes away.
*
* All NextGraph I/O routes through the T01.a `docs` primitives (the REAL
* injected `ng`, never `makeNg`), so this module imports NO `@ng-org` package.
*/ */
import { getConfig, getCurrentUser } from "./polyfill"; import { sparqlUpdate, sparqlQuery } from "./docs";
import type { Nuri } from "./types"; import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { escapeLiteral } from "./sparql";
import type { Nuri, PrincipalId } from "./types";
/** // --- deposit model --------------------------------------------------------
* Deposit a reference into a document's inbox (anticipated SDK shape). Target:
* `ng.inbox_post_link(...)`, sealed to the inbox owner; `from` optional → the /** One deposit as materialized from an inbox document. */
* sender is identified if known, anonymous otherwise. Polyfill: append a export interface Deposit {
* deposit to the emulated inbox document. /** The sender, if identified; `null` when the deposit was anonymous. */
*/ from: PrincipalId | null;
export async function post( /** The consumer-defined payload (opaque here — JSON-serialized in storage). */
targetInbox: Nuri, payload: unknown;
payload: unknown, /** Deposit timestamp (ms epoch). Caller may pass one for determinism. */
opts?: { anonymous?: boolean }, ts: number;
): Promise<void> { }
const { ng } = getConfig();
const from = opts?.anonymous ? null : getCurrentUser(); /** Options for {@link post}. `from` and `ts` are both optional. */
// TODO(polyfill): append { from, payload } to the emulated inbox document export interface PostOptions {
// (targetInbox) via ng.sparql_update. /**
void ng; * Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a
void from; * principal id to identify the sender. Defaults to the current polyfill user
void targetInbox; * ({@link getCurrentUser}) when the property is entirely absent, so callers
void payload; * that want anonymity must pass `from: null` explicitly.
throw new Error("[ng-eventually] inbox.post: polyfill emulation not yet implemented"); */
from?: PrincipalId | null;
/** The payload to deposit (interpreted only by the consumer). */
payload: unknown;
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
* keeps tests deterministic. */
ts?: number;
}
const SHIM = "urn:ng-eventually:inbox";
const P = {
type: `${SHIM}:Deposit`,
from: `${SHIM}:from`,
payload: `${SHIM}:payload`,
ts: `${SHIM}:ts`,
} as const;
// --- session access (shared with the storeRegistry) -----------------------
/** The inbox documents live in the shared wallet, so we reuse the registry's
* injected session provider for the sessionId. Disappears at migration. */
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
// --- SPARQL result helpers ------------------------------------------------
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
// --- deposit (client side) ------------------------------------------------
/**
* Deposit a payload into `targetInbox`.
*
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
* graph, so concurrent deposits don't collide. `from` is optional: pass `null`
* for an anonymous deposit; omit it entirely to default to the current user.
*/
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> {
const from = opts.from === undefined ? getCurrentUser() : opts.from;
const ts = opts.ts ?? Date.now();
const sid = await sessionId();
// A unique subject per deposit (in the inbox graph) — no collisions.
const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`;
const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null));
const fromTriple =
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
const update = `
INSERT DATA {
GRAPH <${targetInbox}> {
<${subject}> a <${P.type}> ;
<${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} .
}
}`;
await sparqlUpdate(sid, update, targetInbox);
}
// --- read / materialize (emulated curator) --------------------------------
/**
* Read (materialize) every deposit currently in `targetInbox`, sorted by `ts`
* ascending. This is the EMULATED CURATOR: at migration a separate curator
* process materializes deposits and this in-client read goes away. The consumer
* interprets each deposit's `payload`.
*/
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
const sid = await sessionId();
const query = `
SELECT ?payload ?ts ?from WHERE {
GRAPH <${targetInbox}> {
?d a <${P.type}> ;
<${P.payload}> ?payload ;
<${P.ts}> ?ts .
OPTIONAL { ?d <${P.from}> ?from }
}
}`;
const result = await sparqlQuery(sid, query, undefined, targetInbox);
const deposits: Deposit[] = [];
for (const row of readBindings(result)) {
const rawPayload = row.payload?.value ?? "null";
let payload: unknown;
try {
payload = JSON.parse(rawPayload);
} catch {
payload = rawPayload; // tolerate a non-JSON literal
}
const tsRaw = row.ts?.value ?? "0";
const ts = Number.parseInt(tsRaw, 10) || 0;
const fromValue = row.from?.value;
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
}
deposits.sort((a, b) => a.ts - b.ts);
return deposits;
}
/** Alias for {@link read} — the name that reads as "run the curator now". */
export const materialize = read;
/**
* Subscription over an inbox — the emulated watcher. Polls {@link read} on an
* interval and invokes `onDeposits` with the full current deposit list whenever
* it changes (grows). Returns an unsubscribe function. The polyfill has no
* native reactive inbox subscription, so this emulates one; at migration it is
* replaced by the real curator's watch. `onDeposits` fires once immediately.
*/
export function watch(
targetInbox: Nuri,
onDeposits: (deposits: Deposit[]) => void,
opts?: { intervalMs?: number },
): () => void {
let stopped = false;
let lastCount = -1;
const intervalMs = opts?.intervalMs ?? 1000;
const tick = async (): Promise<void> => {
if (stopped) return;
try {
const deposits = await read(targetInbox);
if (!stopped && deposits.length !== lastCount) {
lastCount = deposits.length;
onDeposits(deposits);
}
} catch (error) {
console.error("[inbox] watch read failed:", error);
}
};
void tick(); // fire once immediately
const handle = setInterval(() => void tick(), intervalMs);
return () => {
stopped = true;
clearInterval(handle);
};
} }
+6
View File
@@ -23,6 +23,12 @@ export type { Connections, IsolationAccessors } from "./isolation";
export * as accounts from "./accounts"; export * as accounts from "./accounts";
export type { AccountStorage } from "./accounts"; export type { AccountStorage } from "./accounts";
// SPARQL injection-safety helpers — so the app can reuse the same escaping /
// validation when it builds SPARQL by interpolation. `escapeLiteral` for string
// literals, `escapeIri` to embed untrusted values in an IRI, `assertNuri` to
// validate trusted-shaped NURIs before embedding them in an IRI.
export { escapeLiteral, escapeIri, assertNuri } from "./sparql";
// SDK type re-exports — so the app imports these from @ng-eventually/client too, // SDK type re-exports — so the app imports these from @ng-eventually/client too,
// not from @ng-org. `export type` is ERASED at build, so this adds NO runtime // not from @ng-org. `export type` is ERASED at build, so this adds NO runtime
// @ng-org import to the lib (no risk of a duplicate SDK copy in the bundle). // @ng-org import to the lib (no risk of a duplicate SDK copy in the bundle).
+22 -3
View File
@@ -4,7 +4,8 @@
* surface stays identical to `@ng-org/web`'s `ng`. * surface stays identical to `@ng-org/web`'s `ng`.
*/ */
import { getConfig } from "./polyfill"; import { getConfig, getCaps, getCurrentUser } from "./polyfill";
import type { Nuri } from "./types";
export function makeNg(): Record<string, any> { export function makeNg(): Record<string, any> {
return new Proxy({} as Record<string, any>, { return new Proxy({} as Record<string, any>, {
@@ -21,10 +22,28 @@ export function makeNg(): Record<string, any> {
} }
// sparql_update → write guard (emulated write-cap check). // sparql_update → write guard (emulated write-cap check).
// Mirrors the target broker/verifier: a write is refused unless the wallet
// holds the document's WRITE cap. Emulated per-document via CapRegistry.
// args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
if (prop === "sparql_update") { if (prop === "sparql_update") {
return (...args: any[]) => { return (...args: any[]) => {
// TODO(polyfill): reject when getCurrentUser() lacks the write grant const anchor = args[2] as Nuri | undefined;
// on the target document (see access.canWrite). For now, passthrough. const caps = getCaps();
// Passthrough (no regression) unless a WRITE policy exists AND this
// specific document is governed by it. Ungoverned docs (mono-store
// default, no cap declared) flow through exactly as before.
if (
typeof anchor === "string" &&
caps.hasWritePolicy() &&
caps.governsWrite(anchor) &&
!caps.canWrite(anchor, getCurrentUser())
) {
return Promise.reject(
new Error(
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
),
);
}
return ng.sparql_update!(...args); return ng.sparql_update!(...args);
}; };
} }
+102
View File
@@ -0,0 +1,102 @@
/**
* SPARQL string-building safety helpers — shared by every module that builds
* SPARQL by interpolation (inbox, store-registry).
*
* WHY THIS EXISTS: SPARQL injection. When an untrusted value (a username, a
* payload) is spliced verbatim into a query, a `"` closes a literal and a `>`
* closes an IRI, letting the value inject arbitrary triples (or wreck the shim
* graph, which is the trust root mapping accounts → document NURIs). Every
* value that reaches a query MUST pass through one of these helpers first.
*
* TWO POSITIONS, TWO STRATEGIES:
* - LITERAL position (`"..."`): {@link escapeLiteral}. We *escape* rather than
* reject because literals legitimately carry arbitrary text (JSON payloads,
* display names). Escaping is lossless and reversible.
* - IRI position (`<...>`): two cases.
* · Trusted-SHAPED NURIs coming back from `ng` (`did:ng:...`): validate
* with {@link assertNuri} — they should never contain IRI-breaking
* chars; if one does, something upstream is wrong, so we throw rather
* than silently build a broken/injected query.
* · UNTRUSTED values embedded into an IRI (a username used to mint an
* account-subject IRI): {@link escapeIri} percent-encodes every
* IRI-hostile character. We *encode* rather than reject so any username
* (spaces, unicode, punctuation) stays usable, while `<`, `>`, `"`,
* whitespace and control chars can never break out of the IRI.
*/
/**
* Escape a value for embedding inside a SPARQL string literal (`"..."`).
* Escapes backslash, double-quote and the C0 whitespace controls that would
* otherwise terminate or corrupt the literal. Lossless / reversible.
*/
export function escapeLiteral(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
/**
* Delimiter characters that must never appear raw inside a SPARQL/Turtle IRI
* ref (`<...>`): the space plus `< > " { } | ^ backtick \`. Whitespace beyond
* the space and all C0/C1 control characters are handled by the code-point
* check in {@link isIriForbidden}. Any of these would let a value break out of
* the `<...>` and inject arbitrary syntax.
*/
const IRI_FORBIDDEN_DELIMS = /[<>"{}|^`\\ ]/;
/** True if `ch` (a single code point) may not appear raw inside an IRI ref. */
function isIriForbidden(ch: string): boolean {
const code = ch.codePointAt(0)!;
return IRI_FORBIDDEN_DELIMS.test(ch) || code < 0x20 || code === 0x7f;
}
/**
* Percent-encode every IRI-hostile character in `value` so it is safe to embed
* inside a SPARQL IRI ref (`<PREFIX:${escapeIri(value)}>`). Use this for
* UNTRUSTED values (e.g. a username minted into an account-subject IRI):
* encoding keeps every username usable while making breakout impossible.
*
* NOTE: this encodes only the delimiter/whitespace/control set, so ordinary
* printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass
* through unchanged and the resulting IRI stays human-readable.
*/
export function escapeIri(value: string): string {
let out = "";
for (const ch of value) {
if (isIriForbidden(ch)) {
// Percent-encode each UTF-8 byte of the offending character. Also encode
// the chars encodeURIComponent leaves alone but which are IRI-hostile.
out += encodeURIComponent(ch).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
} else {
out += ch;
}
}
return out;
}
/**
* Assert that `nuri` is safe to embed verbatim inside a SPARQL IRI ref. NURIs
* that come back from `ng` are trusted-SHAPED (`did:ng:...` or `urn:...`) and
* should never carry IRI-breaking characters; if one does, we throw rather than
* emit a query that could be malformed or injected. Returns the value unchanged
* so it can be used inline: `<${assertNuri(doc)}>`.
*/
export function assertNuri(nuri: string): string {
if (typeof nuri !== "string" || nuri.length === 0) {
throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`);
}
for (const ch of nuri) {
if (isIriForbidden(ch)) {
throw new Error(
`[sparql] NURI contains IRI-forbidden characters, refusing to embed: ${JSON.stringify(nuri)}`,
);
}
}
return nuri;
}
+20 -9
View File
@@ -34,6 +34,7 @@
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getStoreRegistryDeps } from "./polyfill"; import { getStoreRegistryDeps } from "./polyfill";
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
import type { Nuri, Scope } from "./types"; import type { Nuri, Scope } from "./types";
// --- sharedWalletShim model ---------------------------------------------- // --- sharedWalletShim model ----------------------------------------------
@@ -61,7 +62,11 @@ const P = {
const INDEX_SUBJECT = `${SHIM}:index`; const INDEX_SUBJECT = `${SHIM}:index`;
function accountSubject(username: string): string { function accountSubject(username: string): string {
return `${SHIM}:account:${normalize(username)}`; // The username is UNTRUSTED and lands in an IRI position. Percent-encode it
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
// the `<...>` and inject triples into the shim graph (the account→doc trust
// root). normalize() runs first so the subject stays stable per shim key.
return `${SHIM}:account:${escapeIri(normalize(username))}`;
} }
// --- session / normalization access (injected by the consumer) ------------ // --- session / normalization access (injected by the consumer) ------------
@@ -123,7 +128,7 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
const anchor = await anchorNuri(); const anchor = await anchorNuri();
const query = ` const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE { SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${anchor}> { GRAPH <${assertNuri(anchor)}> {
?acc a <${P.type}> ; ?acc a <${P.type}> ;
<${P.username}> ?username ; <${P.username}> ?username ;
<${P.docPublic}> ?docPublic ; <${P.docPublic}> ?docPublic ;
@@ -184,14 +189,18 @@ export async function ensureAccount(username: string): Promise<AccountRecord> {
const s = await session(); const s = await session();
const anchor = await anchorNuri(); const anchor = await anchorNuri();
const subj = accountSubject(username); const subj = accountSubject(username);
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
// trusted-shaped NURI → assertNuri. `username` is UNTRUSTED text in a LITERAL
// position → escapeLiteral. The doc NURIs come from `ng` but are stored as
// literals here, so they are escaped as literals too (defence in depth).
const update = ` const update = `
INSERT DATA { INSERT DATA {
GRAPH <${anchor}> { GRAPH <${assertNuri(anchor)}> {
<${subj}> a <${P.type}> ; <${subj}> a <${P.type}> ;
<${P.username}> "${username}" ; <${P.username}> "${escapeLiteral(username)}" ;
<${P.docPublic}> "${docPublic}" ; <${P.docPublic}> "${escapeLiteral(docPublic)}" ;
<${P.docProtected}> "${docProtected}" ; <${P.docProtected}> "${escapeLiteral(docProtected)}" ;
<${P.docPrivate}> "${docPrivate}" . <${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
} }
}`; }`;
try { try {
@@ -246,7 +255,9 @@ export async function createEntityDoc(username: string, scope: Scope): Promise<N
try { try {
await sparqlUpdate( await sparqlUpdate(
s.sessionId, s.sessionId,
`INSERT DATA { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> "${entityNuri}" } }`, // indexDoc is a NURI in IRI position → assertNuri; entityNuri is a NURI
// stored as a literal → escapeLiteral.
`INSERT DATA { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" } }`,
indexDoc, indexDoc,
); );
} catch (error) { } catch (error) {
@@ -269,7 +280,7 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
try { try {
const res = await sparqlQuery( const res = await sparqlQuery(
s.sessionId, s.sessionId,
`SELECT ?e WHERE { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`, `SELECT ?e WHERE { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`,
undefined, undefined,
indexDoc, indexDoc,
); );
+14
View File
@@ -48,3 +48,17 @@ test("governsRead / hasReadPolicy distinguish governed from ungoverned documents
expect(caps.governsRead("did:ng:o:doc1")).toBe(true); expect(caps.governsRead("did:ng:o:doc1")).toBe(true);
expect(caps.governsRead("did:ng:o:unknown")).toBe(false); // not declared → not enforced expect(caps.governsRead("did:ng:o:unknown")).toBe(false); // not declared → not enforced
}); });
test("governsWrite / hasWritePolicy distinguish governed from ungoverned documents", () => {
const caps = new CapRegistry();
expect(caps.hasWritePolicy()).toBe(false);
caps.open("did:ng:o:doc1", "private", "alice"); // owner gets the write cap
expect(caps.hasWritePolicy()).toBe(true);
expect(caps.governsWrite("did:ng:o:doc1")).toBe(true);
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
// A public doc grants read to all but its write cap is still owner-only.
const pub = new CapRegistry();
pub.open("did:ng:o:pub", "public", "alice");
expect(pub.hasWritePolicy()).toBe(true);
expect(pub.governsWrite("did:ng:o:pub")).toBe(true);
});
+206
View File
@@ -0,0 +1,206 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { post, read, materialize, watch } from "../src/inbox";
import type { Deposit } from "../src/inbox";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
setCurrentUser,
} from "../src/polyfill";
import type { RegistrySession } from "../src/store-registry";
// This suite injects a fake `ng` via configure() and reuses the storeRegistry's
// injected session provider (inbox docs live in the shared wallet). Restore the
// un-configured state at the end so docs.test.ts's guard still sees null config.
afterAll(() => {
resetConfig();
resetStoreRegistry();
setCurrentUser(null);
});
// NOTE ORDER: the "not configured → throw" case runs first — it exercises the
// registry-deps guard before any configureStoreRegistry() call.
test("throws a clear error when configureStoreRegistry() was not called", async () => {
resetStoreRegistry();
await expect(post("did:ng:o:inbox", { payload: { hi: 1 } })).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
await expect(read("did:ng:o:inbox")).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
});
// --- A stateful fake `ng`: parses the inbox INSERT DATA and answers the read
// SELECT over an in-memory quad store.
interface Quad { g: string; s: string; p: string; o: string }
const INBOX = "urn:ng-eventually:inbox";
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out +=
next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
} else {
out += s[i];
}
}
return out;
}
function makeFakeNg() {
const quads: Quad[] = [];
const doc_create = mock(async (..._a: unknown[]) => "did:ng:o:new");
// Parses one deposit: `<subj> a <Deposit> ; <payload> "..." ; <ts> "..." [; <from> "..."] .`
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
if (!gm) return undefined;
const g = gm[1]!;
const body = gm[2]!;
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
// predicate/object pairs: `a <type>` or `<p> "literal"`.
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? `${INBOX}:Deposit`; // `a` → rdf:type-ish
// Un-escape the SPARQL literal so payload JSON round-trips. Single pass
// over `\x` sequences (reverses the lib's escapeLiteral without the
// double-processing that chained .replace() would cause).
const rawLit = m[2];
const o = rawLit !== undefined ? unescapeLiteral(rawLit) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const anchor = a[3] as string | undefined;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (q.p === `${INBOX}:Deposit`) {
// rdf:type marker — ensure the subject exists.
if (!bySubject.has(q.s)) bySubject.set(q.s, {});
continue;
}
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
payload: { value: r.payload! },
ts: { value: r.ts! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
const TARGET = "did:ng:o:host-inbox";
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION });
setCurrentUser(null);
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(() => {
fake = inject();
});
test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => {
await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 });
expect(fake.sparql_update).toHaveBeenCalledTimes(1);
const call = fake.sparql_update.mock.calls[0]!;
expect(call[0]).toBe("sid-1"); // sessionId from the injected session
expect(call[2]).toBe(TARGET); // anchored to the target inbox
expect(call[1] as string).toContain(`GRAPH <${TARGET}>`);
});
test("post → read round-trips payload, from and ts", async () => {
await post(TARGET, { from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
const deposits = await read(TARGET);
expect(deposits).toHaveLength(1);
expect(deposits[0]).toEqual({ from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
});
test("from is optional — omitting it defaults to the current user", async () => {
setCurrentUser("bob");
await post(TARGET, { payload: { hi: 1 }, ts: 200 });
const deposits = await read(TARGET);
expect(deposits[0]!.from).toBe("bob");
});
test("from: null makes an anonymous deposit even when a current user is set", async () => {
setCurrentUser("bob");
await post(TARGET, { from: null, payload: { hi: 1 }, ts: 200 });
const deposits = await read(TARGET);
expect(deposits[0]!.from).toBeNull();
});
test("read returns deposits sorted by ts ascending and materialize is an alias", async () => {
await post(TARGET, { from: null, payload: "second", ts: 300 });
await post(TARGET, { from: null, payload: "first", ts: 100 });
await post(TARGET, { from: null, payload: "third", ts: 500 });
const deposits = await materialize(TARGET);
expect(deposits.map((d) => d.payload)).toEqual(["first", "second", "third"]);
});
test("read is scoped to one inbox — deposits in another inbox are not returned", async () => {
await post(TARGET, { from: null, payload: "mine", ts: 1 });
await post("did:ng:o:other-inbox", { from: null, payload: "theirs", ts: 2 });
const deposits = await read(TARGET);
expect(deposits.map((d) => d.payload)).toEqual(["mine"]);
});
test("payload with quotes/newlines/backslashes survives the round-trip", async () => {
const payload = { text: 'a "quoted"\nline\\path\ttab' };
await post(TARGET, { from: null, payload, ts: 1 });
const deposits = await read(TARGET);
expect(deposits[0]!.payload).toEqual(payload);
});
test("watch fires immediately then on each new deposit, and unsubscribe stops it", async () => {
const seen: Deposit[][] = [];
const stop = watch(TARGET, (d) => seen.push(d), { intervalMs: 5 });
// Give the immediate tick a chance to run (empty inbox → still fires once).
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[seen.length - 1]).toEqual([]);
await post(TARGET, { from: null, payload: "x", ts: 1 });
await new Promise((r) => setTimeout(r, 20));
const last = seen[seen.length - 1]!;
expect(last.map((d) => d.payload)).toEqual(["x"]);
stop();
const countAfterStop = seen.length;
await post(TARGET, { from: null, payload: "y", ts: 2 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBe(countAfterStop); // no more callbacks after unsubscribe
});
@@ -0,0 +1,81 @@
/**
* ReadCap ACTIVE — end-to-end proof that isolation is enforced by the emulated
* cap registry (not merely by the app's social isolation filter).
*
* This mirrors exactly what the app's storeRegistry wrapper does: create an
* entity document through the REAL registry (`createEntityDoc`), then declare
* its cap policy via `getCaps().open(doc, scope, owner)`. The read filter then
* hides one owner's private document from another principal — the faithful
* per-DOCUMENT NextGraph behavior.
*/
import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
getCaps,
resetCaps,
} from "../src/polyfill";
import { filterReadable } from "../src/read-filter";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
});
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
function inject() {
let n = 0;
const ng = {
doc_create: mock(async () => `did:ng:o:doc${++n}`),
sparql_update: mock(async () => undefined),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeUser: (u) => u.trim() });
resetRegistryCache();
resetCaps();
return ng;
}
test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => {
inject();
// Alice creates a PRIVATE entity document via the REAL store-registry, then
// (as the app wrapper does) declares its cap policy: owner-only read.
const aliceDoc = await createEntityDoc("alice", "private");
getCaps().open(aliceDoc, "private", "alice");
// Bob creates a PUBLIC entity document (world-readable).
const bobDoc = await createEntityDoc("bob", "public");
getCaps().open(bobDoc, "public", "bob");
// The reactive set as the broker would deliver it (mono-store: items carry
// their @graph = the document they live in).
const items = [
{ "@graph": aliceDoc, "@id": "a1", label: "alice-private" },
{ "@graph": bobDoc, "@id": "b1", label: "bob-public" },
];
// Bob cannot read alice's private doc; he CAN read the public one and, since
// the read filter is now under a policy, alice's private item is filtered out.
const bobView = filterReadable(items, getCaps(), "bob").map((i) => i["@id"]);
expect(bobView).toEqual(["b1"]);
// Alice reads her own private doc AND the public one.
const aliceView = filterReadable(items, getCaps(), "alice").map((i) => i["@id"]);
expect(aliceView.sort()).toEqual(["a1", "b1"]);
// Anonymous sees only the public doc.
const anonView = filterReadable(items, getCaps(), null).map((i) => i["@id"]);
expect(anonView).toEqual(["b1"]);
// Sanity: this is the REGISTRY talking, not app-level isolation — the caps
// registry has an active read policy.
expect(getCaps().hasReadPolicy()).toBe(true);
});
+88
View File
@@ -0,0 +1,88 @@
import { test, expect, mock, afterEach } from "bun:test";
import { makeNg } from "../src/ng-proxy";
import {
configure,
resetConfig,
getCaps,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
// This suite injects a fake `ng` via configure() and declares write caps. Reset
// both after each test so the docs.test.ts "not configured" guard still holds
// and no cap policy leaks into another suite.
afterEach(() => {
resetConfig();
resetCaps();
setCurrentUser(null);
});
function fakeNg() {
return { sparql_update: mock(async (..._a: unknown[]) => undefined) };
}
function inject() {
const ng = fakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
return ng;
}
const DOC = "did:ng:o:doc";
const UPDATE = `INSERT DATA { GRAPH <${DOC}> { <s> <p> <o> } }`;
test("write guard: passthrough when NO write policy is declared (no regression)", async () => {
const ng = inject();
setCurrentUser("bob"); // not a writer, but there's no policy at all
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: passthrough for an UNGOVERNED doc even when a policy exists elsewhere", async () => {
const ng = inject();
getCaps().open("did:ng:o:other", "private", "alice"); // policy on another doc
setCurrentUser("bob");
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC); // DOC itself is ungoverned
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: REJECTS when the doc is governed and the user lacks the write cap", async () => {
const ng = inject();
getCaps().open(DOC, "private", "alice"); // alice holds write cap
setCurrentUser("bob"); // bob does not
const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
/write denied/,
);
expect(ng.sparql_update).toHaveBeenCalledTimes(0); // never reached the real ng
});
test("write guard: REJECTS an anonymous (null) user on a governed doc", async () => {
const ng = inject();
getCaps().open(DOC, "public", "alice");
setCurrentUser(null);
const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
/write denied/,
);
expect(ng.sparql_update).toHaveBeenCalledTimes(0);
});
test("write guard: ALLOWS the write-cap holder", async () => {
const ng = inject();
getCaps().open(DOC, "private", "alice");
setCurrentUser("alice"); // owner always holds the write cap
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: passthrough when anchor is omitted (cannot scope the guard)", async () => {
const ng = inject();
getCaps().open(DOC, "private", "alice");
setCurrentUser("bob");
const proxy = makeNg();
await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
+78
View File
@@ -0,0 +1,78 @@
import { test, expect } from "bun:test";
import { escapeLiteral, escapeIri, assertNuri } from "../src/sparql";
// --- escapeLiteral --------------------------------------------------------
test("escapeLiteral escapes backslash, quote and whitespace controls", () => {
expect(escapeLiteral('a"b')).toBe('a\\"b');
expect(escapeLiteral("a\\b")).toBe("a\\\\b");
expect(escapeLiteral("a\nb")).toBe("a\\nb");
expect(escapeLiteral("a\rb")).toBe("a\\rb");
expect(escapeLiteral("a\tb")).toBe("a\\tb");
});
test("escapeLiteral backslash-then-quote order does not double-escape", () => {
// `\` first so a raw `"` never becomes `\"` before the quote pass mangles it.
expect(escapeLiteral('\\"')).toBe('\\\\\\"');
});
test("escapeLiteral output can no longer close a SPARQL literal", () => {
const injected = '" ; <urn:evil> "pwn';
const escaped = escapeLiteral(injected);
// No RAW double-quote survives — every `"` is preceded by a backslash.
expect(/(^|[^\\])"/.test(escaped)).toBe(false);
});
// --- escapeIri ------------------------------------------------------------
test("escapeIri passes ordinary printable identifier chars through unchanged", () => {
expect(escapeIri("alice")).toBe("alice");
expect(escapeIri("a.b-c_d:e/f")).toBe("a.b-c_d:e/f");
});
test("escapeIri percent-encodes every IRI-breaking character", () => {
expect(escapeIri("a>b")).toBe("a%3Eb");
expect(escapeIri("a<b")).toBe("a%3Cb");
expect(escapeIri('a"b')).toBe("a%22b");
expect(escapeIri("a b")).toBe("a%20b");
expect(escapeIri("a\nb")).toBe("a%0Ab");
expect(escapeIri("a\tb")).toBe("a%09b");
expect(escapeIri("a\\b")).toBe("a%5Cb");
});
test("escapeIri neutralises a full breakout attempt", () => {
const attack = 'x> <urn:evil> "pwn';
const encoded = escapeIri(attack);
// The encoded username cannot contain a raw `>`, `<`, `"` or space, so it
// cannot escape the surrounding <PREFIX:...> IRI.
expect(encoded).not.toMatch(/[<>" ]/);
});
test("escapeIri handles unicode without corrupting it (round-trips via decode)", () => {
const u = "élan";
// "é" is a printable letter → left as-is; a space would be encoded.
expect(escapeIri(u)).toBe("élan");
expect(escapeIri("é ")).toBe("é%20");
});
// --- assertNuri -----------------------------------------------------------
test("assertNuri returns valid NURIs unchanged", () => {
expect(assertNuri("did:ng:o:doc1")).toBe("did:ng:o:doc1");
expect(assertNuri("urn:ng-eventually:shim")).toBe("urn:ng-eventually:shim");
expect(assertNuri("did:ng:PRIV")).toBe("did:ng:PRIV");
});
test("assertNuri throws on empty / non-string", () => {
expect(() => assertNuri("")).toThrow(/invalid NURI/);
// @ts-expect-error deliberately wrong type
expect(() => assertNuri(null)).toThrow(/invalid NURI/);
});
test("assertNuri throws on IRI-breaking characters", () => {
expect(() => assertNuri("did:ng:o> <urn:evil")).toThrow(/IRI-forbidden/);
expect(() => assertNuri('did:ng:"x')).toThrow(/IRI-forbidden/);
expect(() => assertNuri("did:ng: x")).toThrow(/IRI-forbidden/);
expect(() => assertNuri("did:ng:\nx")).toThrow(/IRI-forbidden/);
expect(() => assertNuri("did:ng:\tx")).toThrow(/IRI-forbidden/);
});
+113 -6
View File
@@ -43,13 +43,31 @@ test("throws a clear error when configureStoreRegistry() was not called", async
interface Quad { g: string; s: string; p: string; o: string } interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out +=
next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
} else {
out += s[i];
}
}
return out;
}
function makeFakeNg() { function makeFakeNg() {
const quads: Quad[] = []; const quads: Quad[] = [];
let docCounter = 0; let docCounter = 0;
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`); const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
// Parses `INSERT DATA { GRAPH <g> { <s> <p> "o"/<o>/;-lists } }`. // Parses `INSERT DATA { GRAPH <g> { <s> <p> "o"/<o>/;-lists } }`. The literal
// pattern honours backslash-escapes (`\"`, `\\`, `\n`…) so an escaped quote
// inside a value does NOT terminate the literal — this is what proves the
// injection escaping keeps the query well-formed.
const sparql_update = mock(async (...a: unknown[]) => { const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string; const query = a[1] as string;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
@@ -60,16 +78,14 @@ function makeFakeNg() {
const sm = body.match(/<([^>]+)>/); const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined; if (!sm) return undefined;
const s = sm[1]!; const s = sm[1]!;
// Predicate/object pairs: `<p> "o"` or `<p> <o>` (a == rdf:type ignored form // Predicate/object pairs: `<p> "o"` (escape-aware) or `<p> <o>`; `a <type>`.
// handled as literal-free; here the registry only uses <p> "literal" and const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
// <p> <iri> and `a <type>`).
const pairRe = /(?:a|<([^>]+)>)\s+(?:"([^"]*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
// Skip the subject token so we don't treat it as a predicate. // Skip the subject token so we don't treat it as a predicate.
const after = body.slice(body.indexOf(sm[0]) + sm[0].length); const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) { while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish
const o = m[2] ?? m[3] ?? ""; const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o }); quads.push({ g, s, p, o });
} }
return undefined; return undefined;
@@ -190,6 +206,97 @@ test("allAccounts reflects every ensured account", async () => {
expect(names).toEqual(["Gina", "Hank"]); expect(names).toEqual(["Gina", "Hank"]);
}); });
// --- SPARQL injection hardening (F1) --------------------------------------
//
// A malicious username must NOT be able to break out of the literal / IRI it
// lands in and inject arbitrary triples into the shim (the account→doc trust
// root). We inspect the exact SPARQL string the registry hands to sparql_update.
/** The raw INSERT DATA string produced by ensureAccount for `username`. */
async function insertFor(username: string): Promise<string> {
await ensureAccount(username);
const calls = fake.sparql_update.mock.calls;
return calls[calls.length - 1]![1] as string;
}
/** Count RAW (un-escaped) double-quotes — i.e. `"` not preceded by a `\`.
* Strip escaped pairs (`\\`, `\"`, …) first so only delimiter quotes remain. */
function rawQuoteCount(s: string): number {
const withoutEscapes = s.replace(/\\./g, "");
return (withoutEscapes.match(/"/g) ?? []).length;
}
test("injection: username with a quote cannot open extra literals", async () => {
const evil = 'x" ; <urn:evil> "pwn';
const update = await insertFor(evil);
// A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw
// quotes (the delimiters). The injected `"` must have been escaped, so the
// count stays 8 — no extra literal was opened.
expect(rawQuoteCount(update)).toBe(8);
// The escaped username is present as a single literal value — the injected
// `<urn:evil>` survives only as INERT text inside that literal (its
// surrounding quotes are escaped `\"`), never as query syntax.
expect(update).toContain('"x\\" ; <urn:evil> \\"pwn"');
});
test("injection: username with '>' cannot break out of the account-subject IRI", async () => {
const evil = "x> <urn:evil";
const update = await insertFor(evil);
// The account subject is `<urn:ng-eventually:shim:account:...>` — the encoded
// username must NOT contain a raw `>` that would close the IRI early.
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
expect(subjMatch).not.toBeNull();
expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded
expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E
});
test("injection: newline / control chars in username are neutralised", async () => {
const evil = "a\nb\tc";
const update = await insertFor(evil);
// In the literal: escaped to \n / \t (no raw control char).
expect(update).toContain('"a\\nb\\tc"');
// In the IRI subject: percent-encoded.
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
expect(subjMatch[1]).toContain("%0A");
expect(subjMatch[1]).toContain("%09");
});
test("injection: '; DELETE'-style payload stays inert inside the literal", async () => {
const evil = 'x"} ; DELETE WHERE { ?s ?p ?o } ; INSERT DATA { <a> <b> "';
const update = await insertFor(evil);
// The whole attack survives, escaped, as ONE literal value — the injected
// `"}` cannot close the literal/graph, so DELETE/second-INSERT stay text.
expect(update).toContain(escapeLiteralRef(evil));
// Quote count stays even (all delimiters balanced; the injected `"` escaped):
// 4 predicate literals → 8 raw delimiter quotes, nothing extra opened.
expect(rawQuoteCount(update)).toBe(8);
// The injected `"}` did not survive as raw syntax (it was escaped to `\"}`).
expect(update).not.toMatch(/[^\\]"} ; DELETE/);
});
// Local mirror of the lib's escapeLiteral so the assertion is self-checking.
function escapeLiteralRef(v: string): string {
return `"${v
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")}"`;
}
test("injection: a malicious username still round-trips through the shim", async () => {
const evil = 'eve" ; <urn:evil> "x';
const rec = await ensureAccount(evil);
expect(rec.username).toBe(evil);
resetRegistryCache();
const map = await loadShim();
// The stored username came back verbatim (escaping is lossless) under its
// normalized key, and exactly ONE account exists (no injected extra subject).
const key = evil.trim().replace(/^@+/, "").toLowerCase();
expect(map.get(key)?.username).toBe(evil);
expect(map.size).toBe(1);
});
test("normalizeUser defaults to trim when not provided", async () => { test("normalizeUser defaults to trim when not provided", async () => {
const ng = makeFakeNg(); const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });