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:
@@ -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
@@ -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);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 }
|
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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user