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
+20 -9
View File
@@ -34,6 +34,7 @@
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getStoreRegistryDeps } from "./polyfill";
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
import type { Nuri, Scope } from "./types";
// --- sharedWalletShim model ----------------------------------------------
@@ -61,7 +62,11 @@ const P = {
const INDEX_SUBJECT = `${SHIM}:index`;
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) ------------
@@ -123,7 +128,7 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
const anchor = await anchorNuri();
const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${anchor}> {
GRAPH <${assertNuri(anchor)}> {
?acc a <${P.type}> ;
<${P.username}> ?username ;
<${P.docPublic}> ?docPublic ;
@@ -184,14 +189,18 @@ export async function ensureAccount(username: string): Promise<AccountRecord> {
const s = await session();
const anchor = await anchorNuri();
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 = `
INSERT DATA {
GRAPH <${anchor}> {
GRAPH <${assertNuri(anchor)}> {
<${subj}> a <${P.type}> ;
<${P.username}> "${username}" ;
<${P.docPublic}> "${docPublic}" ;
<${P.docProtected}> "${docProtected}" ;
<${P.docPrivate}> "${docPrivate}" .
<${P.username}> "${escapeLiteral(username)}" ;
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
}
}`;
try {
@@ -246,7 +255,9 @@ export async function createEntityDoc(username: string, scope: Scope): Promise<N
try {
await sparqlUpdate(
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,
);
} catch (error) {
@@ -269,7 +280,7 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?e WHERE { GRAPH <${indexDoc}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`,
`SELECT ?e WHERE { GRAPH <${assertNuri(indexDoc)}> { <${INDEX_SUBJECT}> <${P.contains}> ?e } }`,
undefined,
indexDoc,
);