From 9ebf38beadce88f9730e083cf8aeb44fb3c68a89 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 8 Sep 2026 14:35:57 +0200 Subject: [PATCH] Soul attack probes: takeover chain, returnTo origin matrix, brand runtime, attempt-handle binding Co-Authored-By: Claude Opus 5 --- tests/caller-identity-attack-soul.test.ts | 366 ++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 tests/caller-identity-attack-soul.test.ts diff --git a/tests/caller-identity-attack-soul.test.ts b/tests/caller-identity-attack-soul.test.ts new file mode 100644 index 0000000..ecc371f --- /dev/null +++ b/tests/caller-identity-attack-soul.test.ts @@ -0,0 +1,366 @@ +// Soul attack probes for the caller-identity cut, second pass. +// +// Same convention as caller-identity-soul.test.ts: plain `it` is an invariant +// that currently holds; `it.fails` is a hole the cut does not close (vitest +// passes the case while the body fails; close the hole and promote it). +import { afterEach, describe, expect, it } from "vitest"; +import { buildApp, refreshAppSession } from "../src/app.js"; +import { type AppCaller } from "../src/app-caller.js"; +import { builtInAppProfiles, isAllowedReturnOrigin } from "../src/app-profiles.js"; +import { type HeimdallConfig } from "../src/config.js"; +import { entitlementFacts } from "../src/facts.js"; +import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { InMemoryStore } from "../src/store/index.js"; + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all(resources.splice(0).reverse().map((resource) => resource.close())); +}); + +function testConfig(): HeimdallConfig { + return { + serviceName: "heimdall", + host: "127.0.0.1", + port: 4100, + privateCommandHost: "127.0.0.1", + privateCommandPort: 0, + workspaceRoot: "F:/Projects/Heimdall", + dataRoot: "F:/Projects/Heimdall/.heimdall-data", + cultCachePath: "F:/Projects/Heimdall/.heimdall-data/cultcache/test.cc", + publicBaseUrl: "https://heimdall.gamecult.org", + issuer: "https://heimdall.gamecult.org", + daemonId: "yggdrasil-heimdall", + idunnRudpHealth: undefined, + idunnHealthContract: "heimdall.cultnet-rudp-provider-health", + providerHealthIdentityPath: "F:/Projects/Heimdall/.heimdall-data/provider-health-test.cc", + sessionTtlSeconds: 3600, + refreshTtlSeconds: 3600, + stateTtlSeconds: 600, + completionTtlSeconds: 300, + bootstrapSigningPrivateKeyOnMissing: false, + tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), + appSharedSecrets: { repixelizer: "repixelizer-secret", ghostlight: "ghostlight-secret" }, + appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, + appBackendCallbacks: {}, + storage: { backend: "memory", applySchemaOnStartup: true }, + providers: { + discord: { clientId: "discord-client", clientSecret: "discord-secret" }, + patreon: {}, + github: {}, + twitch: {}, + youtube: {}, + spotify: {}, + }, + }; +} + +// The provider "code" doubles as the identity so one runtime can play both +// victim and attacker: code `victim` resolves to discord user `victim`. +function identityByCodeRuntime(): OAuthProviderRuntime { + return { + async exchangeAuthorizationCode({ code }) { + return { accessToken: code, tokenType: "Bearer", scope: ["identify"], raw: {} }; + }, + async resolveIdentity({ accessToken }) { + return { provider: "discord", providerUserId: accessToken, displayName: accessToken, profile: {} }; + }, + async evaluateEntitlements() { + return { facts: [entitlementFacts.appAccess], snapshots: [] }; + }, + }; +} + +async function harness() { + const store = new InMemoryStore(); + const app = await buildApp({ config: testConfig(), store, oauthRuntimes: { discord: identityByCodeRuntime() } }); + resources.push(app); + return { app, store }; +} + +type App = Awaited>["app"]; + +async function start(app: App, payload: Record, headers: Record = {}) { + return app.inject({ method: "POST", url: "/v1/oauth/discord/start", payload, headers }); +} + +async function completeAs(app: App, stateToken: string, who: string) { + return app.inject({ + method: "GET", + url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`, + headers: { accept: "application/json" }, + }); +} + +async function redeem(app: App, appSlug: string, completionCode: string) { + return app.inject({ method: "POST", url: `/v1/apps/${appSlug}/auth-completions/redeem`, payload: { completionCode } }); +} + +const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe("takeover chain: caller-chosen attemptId is never the redemption code", () => { + it("attacker fixes attemptId, victim completes, attemptId does not redeem; the minted code is a fresh uuid v4", async () => { + const { app } = await harness(); + const started = await start(app, { + appSlug: "repixelizer", + mode: "sign_in", + returnTo: "https://repixelizer.gamecult.org/app/", + handoff: { kind: "browser_completion", attemptId: "attacker-fixed" }, + }); + expect(started.statusCode).toBe(201); + + const callback = await completeAs(app, started.json().stateToken as string, "victim"); + expect(callback.statusCode).toBe(201); + const minted = callback.json().completion.code as string; + expect(minted).not.toBe("attacker-fixed"); + expect(minted).toMatch(uuidV4); + + const byAttempt = await redeem(app, "repixelizer", "attacker-fixed"); + expect(byAttempt.statusCode).toBe(410); + expect(byAttempt.json()).toEqual({ error: "invalid_or_expired_completion_code" }); + }); + + it("omitting handoff still mints a uuid v4 code; the attempt handle is absent from the completion", async () => { + const { app, store } = await harness(); + const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" }); + expect(started.statusCode).toBe(201); + const callback = await completeAs(app, started.json().stateToken as string, "victim"); + expect(callback.statusCode).toBe(201); + const code = callback.json().completion.code as string; + expect(code).toMatch(uuidV4); + const stored = (store as unknown as { authCompletions: Map }).authCompletions.get(code); + expect(stored?.attemptId).toBeUndefined(); + }); + + it("handoff.kind backend_callback with an attemptId is refused unless the callbackUrl is configured", async () => { + const { app } = await harness(); + const started = await start(app, { + appSlug: "repixelizer", + mode: "sign_in", + returnTo: "https://repixelizer.gamecult.org/app/", + handoff: { kind: "backend_callback", attemptId: "attacker-fixed", callbackUrl: "https://attacker.example/cb" }, + }); + expect(started.statusCode).toBe(400); + expect(started.json().error).toBe("untrusted_backend_callback"); + }); + + it("an unknown handoff.kind is rejected by the schema", async () => { + const { app } = await harness(); + const started = await start(app, { + appSlug: "repixelizer", + mode: "sign_in", + returnTo: "https://repixelizer.gamecult.org/app/", + handoff: { kind: "evil", attemptId: "attacker-fixed" }, + }); + expect(started.statusCode).toBe(400); + }); + + it("attemptId set to a real victim completion code does not let the attacker's flow touch the victim's completion", async () => { + const { app } = await harness(); + const victimStart = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" }); + const victimCallback = await completeAs(app, victimStart.json().stateToken as string, "victim"); + const victimCode = victimCallback.json().completion.code as string; + + const attackerStart = await start(app, { + appSlug: "repixelizer", + mode: "sign_in", + returnTo: "https://repixelizer.gamecult.org/app/", + handoff: { kind: "browser_completion", attemptId: victimCode }, + }); + const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker"); + expect(attackerCallback.statusCode).toBe(201); + expect(attackerCallback.json().completion.code).not.toBe(victimCode); + + // The victim's code still redeems the victim, once. + const first = await redeem(app, "repixelizer", victimCode); + expect(first.statusCode).toBe(201); + expect(first.json().account.displayName).toBe("victim"); + }); + + it("the redeem route is unauthenticated, so the code is the whole secret: 122 bits of CSPRNG, distinct across mints", async () => { + const store = new InMemoryStore(); + const codes = new Set(); + for (let i = 0; i < 200; i += 1) { + const completion = await store.createAuthCompletion({ + appSlug: "repixelizer", + provider: "discord", + mode: "sign_in", + accountId: "a", + sessionId: "s", + returnTo: "https://repixelizer.gamecult.org/app/", + createdAt: "2026-01-01T00:00:00.000Z", + expiresAt: "2999-01-01T00:00:00.000Z", + payloadJson: {}, + }); + expect(completion.code).toMatch(uuidV4); + codes.add(completion.code); + } + expect(codes.size).toBe(200); + }); +}); + +describe("consumeAuthCompletion: single-use, scoped, expiring", () => { + it("a code redeems exactly once under concurrent redemption, and never for another app", async () => { + const { app } = await harness(); + const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" }); + const callback = await completeAs(app, started.json().stateToken as string, "victim"); + const code = callback.json().completion.code as string; + + const crossApp = await redeem(app, "bifrost", code); + expect(crossApp.statusCode).toBe(410); + + const results = await Promise.all([redeem(app, "repixelizer", code), redeem(app, "repixelizer", code), redeem(app, "repixelizer", code)]); + expect(results.map((r) => r.statusCode).sort()).toEqual([201, 410, 410]); + }); + + it("an expired completion is not consumable, by code or by attempt", async () => { + const store = new InMemoryStore(); + const completion = await store.createAuthCompletion({ + attemptId: "h1", + appSlug: "repixelizer", + provider: "discord", + mode: "sign_in", + accountId: "a", + sessionId: "s", + returnTo: "https://repixelizer.gamecult.org/app/", + createdAt: "2026-01-01T00:00:00.000Z", + expiresAt: "2026-01-01T00:05:00.000Z", + payloadJson: {}, + }); + expect(await store.consumeAuthCompletion("repixelizer", completion.code, "2026-01-01T00:05:00.000Z")).toBeNull(); + expect(await store.consumeAuthCompletionByAttempt("repixelizer", "h1", "2026-01-01T00:05:00.000Z")).toBeNull(); + expect(await store.consumeAuthCompletionByAttempt("bifrost", "h1", "2026-01-01T00:04:00.000Z")).toBeNull(); + expect(await store.consumeAuthCompletionByAttempt("repixelizer", "h1", "2026-01-01T00:04:00.000Z")).not.toBeNull(); + expect(await store.consumeAuthCompletionByAttempt("repixelizer", "h1", "2026-01-01T00:04:00.000Z")).toBeNull(); + }); +}); + +describe("attempt-handle binding: the public start route may attach a completion to any attempt", () => { + // Hole. The private plane creates attempt H for its app and later redeems + // by handle (consumeAuthCompletionByAttempt). The public start route + // accepts handoff.attemptId = H from anyone, and the callback stores a + // completion under attempt_id = H and flips attempt H to "completed". + // Nothing checks that the flow carrying attemptId H was the flow the plane + // started for H. Whoever learns H (Bifrost puts it in the wait-page URL) + // can bind their own provider identity to the app's pending attempt, and + // the app's completeAuth(H) then authenticates the victim's app session as + // the attacker. Exploitability rests on H leaking; the binding gap itself + // is real. Close it by refusing an attemptId at the public start route + // unless the caller is the app (AppCaller), or by having the store refuse + // to create a completion for an attempt whose state token it did not mint. + it.fails("a public flow carrying the app's attempt handle must not complete that attempt", async () => { + const { app, store } = await harness(); + const attempt = await store.createAuthAttempt({ + appSlug: "ghostlight", + provider: "discord", + mode: "sign_in", + returnTo: "https://yggdrasil.gamecult.org/ghostlight/", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 600_000).toISOString(), + }); + + const attackerStart = await start(app, { + appSlug: "ghostlight", + mode: "sign_in", + returnTo: "https://yggdrasil.gamecult.org/ghostlight/", + handoff: { kind: "browser_completion", attemptId: attempt.handle }, + }); + expect(attackerStart.statusCode).toBe(201); + await completeAs(app, attackerStart.json().stateToken as string, "attacker"); + + const after = await store.findAuthAttempt("ghostlight", attempt.handle); + expect(after?.status).toBe("pending"); + const bound = await store.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, new Date().toISOString()); + expect(bound).toBeNull(); + }); +}); + +describe("isAllowedReturnOrigin: origin comparison, not string matching", () => { + const profile = builtInAppProfiles.repixelizer; + const rejected = [ + "https://repixelizer.gamecult.org.evil.com/", + "https://evil.com/?x=https://repixelizer.gamecult.org", + "https://repixelizer.gamecult.org@evil.com/", + "https://evil.com#https://repixelizer.gamecult.org", + "//repixelizer.gamecult.org/app/", + "http://repixelizer.gamecult.org/app/", + "https://repixelizer.gamecult.org:8443/app/", + "https://repixelizer.gamecult.org./app/", + "https://xn--repixelizer-gamecult.org/", + "javascript:alert(1)", + "data:text/html,hi", + "not a url", + "", + "https://repixelizer.gamecult.org.", + "https://repixelizer.gamecult.orgx/", + "https://sub.repixelizer.gamecult.org/", + ]; + it.each(rejected)("rejects %s", (returnTo) => { + expect(isAllowedReturnOrigin(profile, returnTo)).toBe(false); + }); + + // Accepted variants are all the same origin after WHATWG normalisation; + // they are listed so a future "fix" that switches to string comparison + // shows up as a behaviour change here rather than silently in production. + const acceptedSameOrigin = [ + "https://repixelizer.gamecult.org", + "https://repixelizer.gamecult.org/app/", + "https://REPIXELIZER.GameCult.org/app/", + "https://repixelizer.gamecult.org:443/app/", + "https:/\\repixelizer.gamecult.org/app/", + "https://user:pw@repixelizer.gamecult.org/app/", + "https://repixelizer.gamecult.org/app/?returnTo=https://evil.com", + ]; + it.each(acceptedSameOrigin)("accepts %s (same origin after URL parsing)", (returnTo) => { + expect(new URL(returnTo).origin).toBe("https://repixelizer.gamecult.org"); + expect(isAllowedReturnOrigin(profile, returnTo)).toBe(true); + }); + + it("an empty allowlist denies everything rather than passing vacuously", () => { + expect(isAllowedReturnOrigin({ ...profile, allowedReturnOrigins: [] }, "https://repixelizer.gamecult.org/")).toBe(false); + }); + + it("a registered app with no parseable redirect URIs cannot start a flow through HTTP at all (slug enum) and gets an empty allowlist", async () => { + const store = new InMemoryStore(); + store.seedRegisteredApp({ + slug: "newapp", + displayName: "New", + profileVersion: "1", + identityProviders: ["discord"], + entitlementSources: [], + managedConnectionProviders: [], + capabilities: [], + redirectUris: ["garbage"], + clientSecretHash: "x", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } as never); + const app = await buildApp({ config: testConfig(), store, oauthRuntimes: { discord: identityByCodeRuntime() } }); + resources.push(app); + const started = await start(app, { appSlug: "newapp", mode: "sign_in", returnTo: "https://anything.example/" }); + expect(started.statusCode).toBe(400); + }); +}); + +describe("AppCaller brand: compile-time only", () => { + // Hole, by construction rather than by accident: the symbol brand stops a + // TypeScript importer from writing `{ appSlug }`, and nothing else. No + // handler inspects the brand at runtime, so a forged literal that reaches + // refreshAppSession via `as unknown as AppCaller`, plain JS, or a JSON + // round-trip is honoured. The barrier is type-checking plus the two + // constructor call sites, and the grep test in caller-identity-soul.test.ts + // is what stands guard over those call sites. + it.fails("a forged caller literal is refused at runtime", async () => { + const { app } = await harness(); + const context = (app as unknown as { heimdallContext: Parameters[0] }).heimdallContext; + const forged = { appSlug: "repixelizer" } as unknown as AppCaller; + const result = await refreshAppSession(context, "repixelizer", forged, { + refreshToken: "not-a-token", + entitlementPolicies: [{ kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] }], + } as never); + // With a forged caller the request gets past the caller gate and fails + // later on the bogus refresh token (401 invalid_refresh_token / similar); + // a runtime brand check would return app_auth_required here. + expect(result.body).toMatchObject({ error: "app_auth_required" }); + }); +});