Widen the blank-target predicate; answer the app-secret gate before the target checks

isUsableConnectionTarget now rejects targets made only of whitespace, controls,
default-ignorable code points and U+2800. The blank-target and
creator_connection_required refusals move after the app-secret gate, so a
caller without the secret gets 401.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-09 11:57:13 +00:00
commit bda14e330b
2 changed files with 23 additions and 20 deletions

View file

@ -265,11 +265,13 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo
/**
* A connection target names something: at least one character that is not
* whitespace or an invisible format character (U+200B, U+FEFF and the like).
* The one definition the start gate and the callback mint both use.
* whitespace, a control, a default-ignorable code point (format characters,
* variation selectors, the Hangul and Khmer fillers, the combining grapheme
* joiner) or the Braille blank U+2800. The one definition the start gate and
* the callback mint both use.
*/
export function isUsableConnectionTarget(targetId: string): boolean {
return /[^\s\p{Cf}]/u.test(targetId);
return /[^\s\p{Z}\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}\u2800]/u.test(targetId);
}
/**

View file

@ -702,23 +702,6 @@ export async function startOAuthFlow(
};
}
if (input.connection !== undefined && !isUsableConnectionTarget(input.connection.targetId)) {
return {
statusCode: 400,
body: { error: "invalid_connection_target", detail: "connection.targetId must name a target." },
};
}
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
return {
statusCode: 400,
body: {
error: "creator_connection_required",
detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`,
},
};
}
const descriptor = providerCatalog[provider];
// A provider that never mints an account attaches an identity only through
// a bound link: the user's own access token, since the app's word does not
@ -818,6 +801,24 @@ export async function startOAuthFlow(
return { statusCode: 401, body: { error: "app_auth_required" } };
}
// After the app-secret gate, so a caller without the secret learns nothing about what a target must look like.
if (input.connection !== undefined && !isUsableConnectionTarget(input.connection.targetId)) {
return {
statusCode: 400,
body: { error: "invalid_connection_target", detail: "connection.targetId must name a target." },
};
}
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
return {
statusCode: 400,
body: {
error: "creator_connection_required",
detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`,
},
};
}
const providerConfig = config.providers[provider];
if (!providerConfig.clientId) {
return {