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:
@@ -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: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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 }
|
||||
|
||||
/** 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[] = [];
|
||||
let docCounter = 0;
|
||||
|
||||
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 query = a[1] as string;
|
||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
@@ -60,16 +78,14 @@ function makeFakeNg() {
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const s = sm[1]!;
|
||||
// Predicate/object pairs: `<p> "o"` or `<p> <o>` (a == rdf:type ignored form
|
||||
// handled as literal-free; here the registry only uses <p> "literal" and
|
||||
// <p> <iri> and `a <type>`).
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"([^"]*)"|<([^>]+)>)/g;
|
||||
// Predicate/object pairs: `<p> "o"` (escape-aware) or `<p> <o>`; `a <type>`.
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
// Skip the subject token so we don't treat it as a predicate.
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
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 });
|
||||
}
|
||||
return undefined;
|
||||
@@ -190,6 +206,97 @@ test("allAccounts reflects every ensured account", async () => {
|
||||
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 () => {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
|
||||
Reference in New Issue
Block a user