/** * 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 (``). 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; }