fix(client): reserve the discovery @index account key (prevent user collision)
The special discovery-index account was keyed as "@index", which the consumer's normalizeUsername collapses to "index" — colliding with a real user named "index" (who could then hijack/tamper the global index document). Introduce a reserved-account namespace (a sentinel key unreachable by any typed username) so the index account can never collide with user input. Test proves a user named "index"/"@index" resolves to a DIFFERENT document than the reserved index account. 80 tests pass; tsc rc=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,8 +65,43 @@ function accountSubject(username: string): string {
|
||||
// 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))}`;
|
||||
// root). accountKey() runs first so the subject stays stable per shim key.
|
||||
return `${SHIM}:account:${escapeIri(accountKey(username))}`;
|
||||
}
|
||||
|
||||
// --- reserved accounts -----------------------------------------------------
|
||||
//
|
||||
// Some accounts are internal to the lib (e.g. the discovery index owner) and
|
||||
// must NOT collide with any user-chosen username. A reserved account is created
|
||||
// via {@link reservedAccount}, which marks the name with a sentinel PREFIX that
|
||||
// `normalizeUser` (consumer-injected) can never produce: it strips a leading
|
||||
// `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved
|
||||
// keys therefore live in a disjoint namespace from every normalized username —
|
||||
// a real user named "index"/"@index" can never resolve to the reserved
|
||||
// `reservedAccount("index")` account.
|
||||
const RESERVED_PREFIX = "\u0000reserved:";
|
||||
|
||||
/**
|
||||
* Wrap an internal account name so it occupies a key that no user input can
|
||||
* produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount}
|
||||
* (and the other registry calls) instead of a bare username.
|
||||
*/
|
||||
export function reservedAccount(name: string): string {
|
||||
return `${RESERVED_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
/** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */
|
||||
function isReserved(username: string): boolean {
|
||||
return username.startsWith(RESERVED_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shim/cache key for an account. Reserved accounts bypass `normalizeUser`
|
||||
* entirely and key on their sentinel-prefixed name, so they cannot collide with
|
||||
* a normalized username; everyone else normalizes as usual.
|
||||
*/
|
||||
function accountKey(username: string): string {
|
||||
return isReserved(username) ? username : normalize(username);
|
||||
}
|
||||
|
||||
// --- session / normalization access (injected by the consumer) ------------
|
||||
@@ -147,7 +182,7 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
||||
for (const row of readBindings(result)) {
|
||||
const username = bindingValue(row, "username");
|
||||
if (!username) continue;
|
||||
map.set(normalize(username), {
|
||||
map.set(accountKey(username), {
|
||||
username,
|
||||
docPublic: bindingValue(row, "docPublic"),
|
||||
docProtected: bindingValue(row, "docProtected"),
|
||||
@@ -180,7 +215,7 @@ async function createDoc(): Promise<Nuri> {
|
||||
*/
|
||||
export async function ensureAccount(username: string): Promise<AccountRecord> {
|
||||
const map = await loadShim();
|
||||
const key = normalize(username);
|
||||
const key = accountKey(username);
|
||||
const existing = map.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user