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:
@@ -40,16 +40,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as inbox from "./inbox";
|
import * as inbox from "./inbox";
|
||||||
import { ensureAccount } from "./store-registry";
|
import { ensureAccount, reservedAccount } from "./store-registry";
|
||||||
import type { Nuri, PrincipalId } from "./types";
|
import type { Nuri, PrincipalId } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The reserved SPECIAL ACCOUNT that owns the global discovery index in the
|
* The reserved SPECIAL ACCOUNT that owns the global discovery index in the
|
||||||
* polyfill. A normal shim account (username), so its scope documents are created
|
* polyfill. It hosts the index document but is never a real user. It lives in
|
||||||
* on first sight like any other account — but it is never a real user; it only
|
* the registry's RESERVED namespace ({@link reservedAccount}), whose key
|
||||||
* hosts the index document. Disappears at migration (see file header).
|
* `normalizeUser` can never produce — so a user named "index"/"@index" can NOT
|
||||||
|
* hijack it (they would normalize to "index", a disjoint key). Disappears at
|
||||||
|
* migration (see file header).
|
||||||
*/
|
*/
|
||||||
export const INDEX_ACCOUNT = "@index";
|
export const INDEX_ACCOUNT = reservedAccount("index");
|
||||||
|
|
||||||
/** One entry as materialized from the discovery index. */
|
/** One entry as materialized from the discovery index. */
|
||||||
export interface IndexEntry {
|
export interface IndexEntry {
|
||||||
|
|||||||
@@ -65,8 +65,43 @@ function accountSubject(username: string): string {
|
|||||||
// The username is UNTRUSTED and lands in an IRI position. Percent-encode it
|
// The username is UNTRUSTED and lands in an IRI position. Percent-encode it
|
||||||
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
|
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
|
||||||
// the `<...>` and inject triples into the shim graph (the account→doc trust
|
// the `<...>` and inject triples into the shim graph (the account→doc trust
|
||||||
// root). normalize() runs first so the subject stays stable per shim key.
|
// root). accountKey() runs first so the subject stays stable per shim key.
|
||||||
return `${SHIM}:account:${escapeIri(normalize(username))}`;
|
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) ------------
|
// --- 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)) {
|
for (const row of readBindings(result)) {
|
||||||
const username = bindingValue(row, "username");
|
const username = bindingValue(row, "username");
|
||||||
if (!username) continue;
|
if (!username) continue;
|
||||||
map.set(normalize(username), {
|
map.set(accountKey(username), {
|
||||||
username,
|
username,
|
||||||
docPublic: bindingValue(row, "docPublic"),
|
docPublic: bindingValue(row, "docPublic"),
|
||||||
docProtected: bindingValue(row, "docProtected"),
|
docProtected: bindingValue(row, "docProtected"),
|
||||||
@@ -180,7 +215,7 @@ async function createDoc(): Promise<Nuri> {
|
|||||||
*/
|
*/
|
||||||
export async function ensureAccount(username: string): Promise<AccountRecord> {
|
export async function ensureAccount(username: string): Promise<AccountRecord> {
|
||||||
const map = await loadShim();
|
const map = await loadShim();
|
||||||
const key = normalize(username);
|
const key = accountKey(username);
|
||||||
const existing = map.get(key);
|
const existing = map.get(key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
resetConfig,
|
resetConfig,
|
||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
} from "../src/polyfill";
|
} from "../src/polyfill";
|
||||||
import { resetRegistryCache } from "../src/store-registry";
|
import { resetRegistryCache, ensureAccount } from "../src/store-registry";
|
||||||
import type { RegistrySession } from "../src/store-registry";
|
import type { RegistrySession } from "../src/store-registry";
|
||||||
|
|
||||||
// discovery.ts submits to / reads from a global index owned by a RESERVED
|
// discovery.ts submits to / reads from a global index owned by a RESERVED
|
||||||
@@ -209,8 +209,34 @@ test("from: null makes an anonymous submission", async () => {
|
|||||||
expect(entries[0]!.from).toBeNull();
|
expect(entries[0]!.from).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("INDEX_ACCOUNT is the reserved special account", () => {
|
test("INDEX_ACCOUNT lives in the reserved namespace (no typed username can equal it)", () => {
|
||||||
expect(INDEX_ACCOUNT).toBe("@index");
|
// The index account occupies a key no user input can produce: it is prefixed
|
||||||
|
// with a NUL control char, which a user cannot type into a username field and
|
||||||
|
// which no `normalizeUser` output (a typeable value) contains. So it is
|
||||||
|
// disjoint from the keys "index" / "@index" a hostile user would submit.
|
||||||
|
expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel
|
||||||
|
expect(INDEX_ACCOUNT).not.toBe("index");
|
||||||
|
expect(INDEX_ACCOUNT).not.toBe("@index");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a user named 'index'/'@index' does NOT resolve to the index account's document", async () => {
|
||||||
|
// The discovery index lives on INDEX_ACCOUNT. A hostile (or unlucky) user who
|
||||||
|
// registers as "index" or "@index" normalizes to key "index" — which must be
|
||||||
|
// a DISJOINT key from the reserved index account, so they get their own
|
||||||
|
// documents and cannot hijack / read-write the global index document.
|
||||||
|
const indexRecord = await ensureAccount(INDEX_ACCOUNT);
|
||||||
|
|
||||||
|
// A real user "index" — same normalized form as "@index".
|
||||||
|
const userIndex = await ensureAccount("index");
|
||||||
|
expect(userIndex.docPublic).not.toBe(indexRecord.docPublic);
|
||||||
|
expect(userIndex.docProtected).not.toBe(indexRecord.docProtected);
|
||||||
|
expect(userIndex.docPrivate).not.toBe(indexRecord.docPrivate);
|
||||||
|
|
||||||
|
// "@index" must land on the SAME account as "index" (both normalize to
|
||||||
|
// "index") — and still NOT on the reserved index account.
|
||||||
|
const userAtIndex = await ensureAccount("@index");
|
||||||
|
expect(userAtIndex.docPublic).toBe(userIndex.docPublic);
|
||||||
|
expect(userAtIndex.docPublic).not.toBe(indexRecord.docPublic);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("watchIndex fires immediately then when a submission arrives", async () => {
|
test("watchIndex fires immediately then when a submission arrives", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user