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