Heimdall/tests/caller-identity-attack-soul.test.ts
Meta Vens 1c38d12050 Add Joystick as a confidential, rotating managed-connection provider
Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves
identity by channel id, and is a streampixels managed-connection provider only:
it is not an identity provider for the profile (ruling signin-waits-for-public-bot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:13:40 +02:00

359 lines
16 KiB
TypeScript

// 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<unknown> }> = [];
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: {},
joystick: {},
},
};
}
// 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<ReturnType<typeof harness>>["app"];
async function start(app: App, payload: Record<string, unknown>, headers: Record<string, string> = {}) {
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<string, { attemptId?: string }> }).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<string>();
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", () => {
// Closed (R21.1). The private plane creates attempt H for its app and later
// redeems by handle (consumeAuthCompletionByAttempt). handoff.attemptId is
// no longer read from the request body at all (normalizeOAuthHandoff always
// returns a bare `{ kind: "browser_completion" }` for a public caller); the
// only path by which an attempt handle reaches the signed state token is
// `startOAuthFlow`'s `trustedBrowserAttemptId` option, which only
// `beginAuth` (private-command-plane.ts) can set, from the handle it just
// minted server-side. A value that never crosses the public boundary cannot
// be chosen by a stranger, so a public flow carrying H as body JSON now has
// no effect on H at all.
it("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;
// Narrow rather than cast: the lookup is Partial-typed, and a missing
// built-in should fail this suite loudly instead of being asserted away.
if (!profile) {
throw new Error("built-in repixelizer profile is missing");
}
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);
});
// The registered-app path this test covered (seedRegisteredApp,
// resolveAppProfile's non-built-in branch) was deleted in R21.3: every HTTP
// route already enumerates the four built-in slugs in its own JSON schema,
// so a registered row could never be reached over HTTP in the first place.
// An unknown slug is now refused by that schema directly.
it("an unknown appSlug cannot start a flow through HTTP at all (slug enum)", async () => {
const { app } = await harness();
const started = await start(app, { appSlug: "newapp", mode: "sign_in", returnTo: "https://anything.example/" });
expect(started.statusCode).toBe(400);
});
});
describe("AppCaller brand: enforced by a module-private WeakSet (R21.2)", () => {
// Closed. brandCaller (app-caller.ts) registers every AppCaller it mints in
// a WeakSet that never leaves the module; isAppCaller is the only way a
// consumer may trust one, and startOAuthFlow / refreshAppSession now call
// it instead of trusting the AppCaller | null type alone. A forged literal
// — even one carrying the callerBrand symbol key by hand — was never added
// to the set and is rejected regardless of shape or cast.
it("a forged caller literal is refused at runtime", async () => {
const { app } = await harness();
const context = (app as unknown as { heimdallContext: Parameters<typeof refreshAppSession>[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" });
});
});