diff --git a/src/app.ts b/src/app.ts index 3079694..a1fe611 100644 --- a/src/app.ts +++ b/src/app.ts @@ -517,6 +517,16 @@ export async function startOAuthFlow( }; } + // A connection target is app-backend authority: it rides only on connect + // and link starts, which are the flows gated by x-heimdall-app-secret. + // A sign_in start is open to any browser and must not carry one. + if (input.mode === "sign_in" && input.connection !== undefined) { + return { + statusCode: 400, + body: { error: "connection_not_allowed_in_sign_in", detail: "sign_in start must not carry a connection target." }, + }; + } + if (callbackMint(profile, provider, input.mode, input.connection) === "refused") { return { statusCode: 400, diff --git a/tests/link-binding.test.ts b/tests/link-binding.test.ts index 49f6662..9f52acb 100644 --- a/tests/link-binding.test.ts +++ b/tests/link-binding.test.ts @@ -120,6 +120,18 @@ describe("link start names its initiator", () => { }); }); +describe("sign_in start carries no connection target", () => { + it("refuses connection.targetId on sign_in with connection_not_allowed_in_sign_in, with or without app auth", async () => { + const r = await rig("d-si"); + const connection = { kind: "creator", targetId: "victim-target" }; + for (const headers of [{}, appAuth]) { + const res = await start(r, { mode: "sign_in", connection }, headers); + expect([res.statusCode, res.json().error]).toEqual([400, "connection_not_allowed_in_sign_in"]); + } + expect((await start(r, { mode: "sign_in" }, {})).statusCode).toBe(201); + }); +}); + describe("link callback holds the provider identity to the initiator", () => { it("attaches an unlinked identity to the initiating account, not a new one", async () => { const r = await rig("d-new");