Refuse a connection target on sign_in start

Connection targets ride only on connect and link starts, which carry
x-heimdall-app-secret. A sign_in start is open to any browser; with a
connection it now returns 400 connection_not_allowed_in_sign_in.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-03 13:30:22 +00:00
commit 84c54fbb5e
2 changed files with 22 additions and 0 deletions

View file

@ -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,

View file

@ -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");