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