diff --git a/packages/cli/src/serve/workspace-providers-status.test.ts b/packages/cli/src/serve/workspace-providers-status.test.ts index c4db8bb309f..3b438f940d2 100644 --- a/packages/cli/src/serve/workspace-providers-status.test.ts +++ b/packages/cli/src/serve/workspace-providers-status.test.ts @@ -562,6 +562,83 @@ describe('createWorkspaceProvidersStatusProvider', () => { expect(result.initialized).toBe(false); }); + it('does not corrupt a pathless URL with a port followed by prose email (#8136)', async () => { + coreMock.throwModelsConfigError = true; + coreMock.modelsConfigErrorMessage = + 'Cannot reach https://api.example:8443 - contact admin@example.com'; + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + modelProviders: { + openai: [{ id: 'model-a', name: 'Model A' }], + }, + }); + + const result = await provider(workspace, true); + + expect(result.errors?.[0]?.error).toBe( + 'Cannot reach https://api.example:8443 - contact admin@example.com', + ); + }); + + it('does not corrupt a pathless URL without a port followed by prose email (#8136)', async () => { + coreMock.throwModelsConfigError = true; + coreMock.modelsConfigErrorMessage = + 'Cannot reach https://api.example - contact admin@example.com'; + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + modelProviders: { + openai: [{ id: 'model-a', name: 'Model A' }], + }, + }); + + const result = await provider(workspace, true); + + expect(result.errors?.[0]?.error).toBe( + 'Cannot reach https://api.example - contact admin@example.com', + ); + }); + + it('strips credentials from a pathless URL and keeps host and prose (#8136)', async () => { + coreMock.throwModelsConfigError = true; + coreMock.modelsConfigErrorMessage = + 'Failed https://user:pass@host.example:8443 - contact admin@example.com'; + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + modelProviders: { + openai: [{ id: 'model-a', name: 'Model A' }], + }, + }); + + const result = await provider(workspace, true); + + expect(result.errors?.[0]?.error).toBe( + 'Failed https://host.example:8443 - contact admin@example.com', + ); + expect(JSON.stringify(result)).not.toContain('user:pass@'); + }); + + it('strips a password containing @ in full (#8136)', async () => { + coreMock.throwModelsConfigError = true; + coreMock.modelsConfigErrorMessage = + 'Failed loading provider https://user:p@ssw0rd-tail@broken.example/v1'; + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + modelProviders: { + openai: [{ id: 'model-a', name: 'Model A' }], + }, + }); + + const result = await provider(workspace, true); + + expect(JSON.stringify(result)).toContain('https://broken.example/v1'); + expect(JSON.stringify(result)).not.toContain('ssw0rd-tail'); + expect(JSON.stringify(result)).not.toContain('p@ssw0rd'); + }); + async function writeUserSettings(settings: Record) { await fs.writeFile( path.join(qwenHome, 'settings.json'), diff --git a/packages/cli/src/serve/workspace-providers-status.ts b/packages/cli/src/serve/workspace-providers-status.ts index eb9fcce8acf..7ee2849d948 100644 --- a/packages/cli/src/serve/workspace-providers-status.ts +++ b/packages/cli/src/serve/workspace-providers-status.ts @@ -260,7 +260,7 @@ function sanitizeProviderWarning(warning: string): string { const segmentEnd = findUrlSegmentEnd(warning, next.index, next.marker); const segment = warning.slice(next.index, segmentEnd); - result += sanitizeProviderWarningSegment(segment, next.marker.length); + result += sanitizeProviderWarningSegment(segment); index = segmentEnd; next = findNextUrlStart(warning, index); @@ -295,36 +295,22 @@ function findUrlSegmentEnd( return Math.min(lineEnd, nextUrl?.index ?? value.length); } -function sanitizeProviderWarningSegment( - segment: string, - markerLength: number, -): string { - const at = segment.indexOf('@', markerLength); - if ( - at !== -1 && - hasCredentialPrefix(segment, markerLength, at) && - segment[at + 1] !== undefined && - /[A-Za-z0-9.[\]-]/.test(segment[at + 1]) - ) { - return `${segment.slice(0, markerLength)}${segment.slice(at + 1)}`; +function sanitizeProviderWarningSegment(segment: string): string { + // When the whole segment is a URL that sanitizeProviderBaseUrl confirms + // carries real userinfo (it changes the segment), use its result directly. + // This handles space-containing-credential URLs that URL_LIKE_PATTERN cannot + // match past the whitespace, and the '@'-in-password shape (last '@' wins). + // The veto in sanitizeProviderBaseUrl leaves pathless-URL + prose-email + // shapes unchanged, so a prose email's '@' is never stripped. #8136. + const sanitized = sanitizeProviderBaseUrl(segment); + if (sanitized !== segment) { + return sanitized; } - return segment.replace(URL_LIKE_PATTERN, (url) => sanitizeProviderBaseUrl(url), ); } -function hasCredentialPrefix( - segment: string, - markerLength: number, - at: number, -): boolean { - const colon = segment.indexOf(':', markerLength); - if (colon === -1 || colon > at) return false; - const username = segment.slice(markerLength, colon); - return !/[/?#\s'"`<>]/.test(username); -} - function buildCurrent( authType: AuthType | undefined, modelId: string | undefined, diff --git a/packages/cli/src/utils/acpModelUtils.test.ts b/packages/cli/src/utils/acpModelUtils.test.ts index 9291ec67238..9cf898b13aa 100644 --- a/packages/cli/src/utils/acpModelUtils.test.ts +++ b/packages/cli/src/utils/acpModelUtils.test.ts @@ -249,6 +249,174 @@ describe('acpModelUtils', () => { ['https://user:p?x@api.example/v1', 'https://api.example/v1'], ['https://user:p#x@api.example/v1', 'https://api.example/v1'], ['https://user:secret@api.example', 'https://api.example'], + // #8136: pathless URL + prose email shapes. WHATWG misparses these as + // userinfo; the veto (all-digit port before first whitespace) protects the + // with-port shape, and the pathless-prose guard protects the no-colon shape. + [ + 'https://api.example:8443 - contact admin@example.com', + 'https://api.example:8443 - contact admin@example.com', + ], + [ + 'https://api.example - contact admin@example.com', + 'https://api.example - contact admin@example.com', + ], + [ + 'https://ollama.local - contact admin@example.com', + 'https://ollama.local - contact admin@example.com', + ], + // Credentials + pathless + email: strip the credential, keep host + prose. + [ + 'https://user:pass@host.example:8443 - contact admin@example.com', + 'https://host.example:8443 - contact admin@example.com', + ], + // Space-in-password: the last '@' within the bounded authority is the real + // userinfo terminator (the password's '@' precedes it). #8136 R1-1/R1-3. + [ + 'https://user:sec ret@host.example/v1 - contact admin@example.com', + 'https://host.example/v1 - contact admin@example.com', + ], + ['https://user:p@ss word@host.example/v1', 'https://host.example/v1'], + // R1-2 KNOWN RESIDUAL: digit-prefix + space password is locally + // indistinguishable from a dotless host + port + prose email; the veto + // fires and the credential leaks. Same tradeoff class as R5-7, pending + // maintainer sign-off. #8136 R1-2. + ['https://user:1234 secret@host', 'https://user:1234 secret@host'], + // #8136 R3: passwords/hostnames the previous host-shaped-char heuristic + // mishandled. The structural terminator scan resolves these. + // Password containing '@' (pathless): strip at the LAST '@', not the first. + ['https://user:p@ss@host', 'https://host'], + // Underscore-leading host (previously outside HOST_SHAPED_CHAR): strip. + ['https://user:pass@_host', 'https://_host'], + // Tab immediately after '@' (WHATWG strips it as userinfo terminator): strip. + [`https://user:pass@\thost`, 'https://\thost'], + // Password containing '@' AND whitespace (bounded authority): the last '@' + // within the bounded authority is the terminator. + ['https://user:p@ss word@host.example/v1', 'https://host.example/v1'], + // Whitespace in the password (pathless): the '@' whose following text is a + // clean hostname is the terminator, so the password's whitespace does not + // end the scan. #8136 R3-5. + ['https://user:pass word@host', 'https://host'], + ['https://user:p@ss word@host', 'https://host'], + // Unicode whitespace in the password (WHATWG percent-encodes it): strip. + ['https://user:pa ss@host', 'https://host'], + // #8136 R3-6: prose with a path - the prose email's '@' must not destroy the + // host. The pathless prose veto fires regardless of a later delimiter. + [ + 'https://ollama.local - email admin@example.com or check /var/log/qwen', + 'https://ollama.local - email admin@example.com or check /var/log/qwen', + ], + // #8136 R4-1: catch-branch (new URL throws) prose '@' after whitespace must + // not become the strip point - strip the credential, keep host + prose. + ['https://user:pass@host - ping admin@', 'https://host - ping admin@'], + // R9-7 KNOWN RESIDUAL: a real host + prose email with a VALID email domain + // is indistinguishable from a real credential whose terminator is that + // email's '@' - the prose email's host replaces the real host. (A '%zz' + // invalid domain makes CLEAN_HOST_AFTER fail and passes spuriously; this + // pins the real-domain behavior instead.) Same class as R5-1, pending + // maintainer sign-off. + ['https://user@host - contact admin@example.com', 'https://example.com'], + // #8136 R4-3: whitespace-less multi-'@' prose - the FIRST '@' ends the + // userinfo; the prose email's '@' is not a terminator. + [ + 'https://u:p@h,see(admin@example.com)', + 'https://h,see(admin@example.com)', + ], + [ + 'https://u:p@h,see(admin@example.com)/x', + 'https://h,see(admin@example.com)/x', + ], + // #8136 R4-4/R4-5: backslash - a Windows domain\user:pass@ credential strips + // as a single userinfo run, while '\' terminates the authority for prose. + ['https://DOMAIN\\user:pass@proxy', 'https://proxy'], + ['https://user:pass@host\\path', 'https://host\\path'], + // #8136 R5-3: an '@' in the password with an underscore host still strips - + // CLEAN_HOST_AFTER accepts underscore-leading hosts. + ['https://user:p@ss@_host', 'https://_host'], + ['https://user:pass@_host:8080', 'https://_host:8080'], + // #8136 R5-14: URL schemes are case-insensitive; uppercase must still strip. + ['HTTPS://user:pass@host/v1', 'HTTPS://host/v1'], + ['HTTP://user:pass@host', 'HTTP://host'], + // #8136 R5-2: a leading '@' (empty userinfo) does not loop and is a no-op. + ['https://@host', 'https://host'], + // #8136 R5-1/R5-7 KNOWN RESIDUAL: a dotted username + digit-prefix password + // followed by space is locally indistinguishable from a dotted host + port + // + prose email; the veto fires and the credential leaks. Same tradeoff as + // R1-2, pending maintainer sign-off. + ['https://foo.bar:1234 secret@host', 'https://foo.bar:1234 secret@host'], + // #8136 R1-7: IPv6 bracket + port + prose email must stay unchanged. The + // prose veto skips the bracket's inner colons and accepts an em-dash/empty + // port candidate. + [ + 'https://[::1]:8443 — contact admin@example.com', + 'https://[::1]:8443 — contact admin@example.com', + ], + [ + 'https://ollama.local: please contact admin@example.com', + 'https://ollama.local: please contact admin@example.com', + ], + // #8136 R6-1: a port followed by punctuation (`;`/`,`/`.`) + prose email. + [ + 'https://api.example:8443; contact admin@example.com', + 'https://api.example:8443; contact admin@example.com', + ], + // #8136 R7-3: a port followed by multiple punctuation chars + prose email. + [ + 'https://api.example:8443,. contact admin@example.com', + 'https://api.example:8443,. contact admin@example.com', + ], + // #8136 R7-10: a Unicode (IDN) host is a clean hostname — strip the credential. + ['https://user:pass@例子.测试/v1', 'https://例子.测试/v1'], + // #8136 R7-5 KNOWN RESIDUAL: an '@' in the path (npm scoped) with a + // host:port-shaped authority before it is stripped by the no-'@' fallback + // (base has the same behavior — the between-run has no whitespace). Same + // tradeoff class, pending maintainer sign-off. + [ + 'https://registry.example: check /node_modules/@qwen/pkg', + 'https://qwen/pkg', + ], + // #8136 R7-1 KNOWN RESIDUAL: a colonless username containing whitespace + // (`user @host`) is indistinguishable from a prose `host @host` shape; + // the prose veto fires and it leaks. Pending maintainer sign-off. + ['https://user @host.example/v1', 'https://user @host.example/v1'], + // #8136 R5-12: a backslash-free authority with a later prose `a:b@c` is NOT + // misread as a Windows credential by findAuthorityEnd (R5-12 fixed the + // windowsCred scan bound). The remaining leak is the R5-1 residual class. + ['https://user:pass@host a:b@c', 'https://c'], + // #8136 R5-1/R6-3 KNOWN RESIDUAL: a real terminator in the first '@' with a + // dotless host after it, followed by prose with an '@host', is + // indistinguishable from a password containing '@' + a real terminator after + // whitespace (`user:p@ss word@host` -> `host`). The prose shape's host gets + // replaced by the prose email's domain; same tradeoff class as R1-2, + // pending maintainer sign-off. + [ + 'https://user:pass@ollama - contact admin@example.com', + 'https://example.com', + ], + [ + 'https://user:p@ss word@host.example - contact admin@example.com', + 'https://example.com', + ], + // #8136 repro-1 (with-path port + prose email): must stay unchanged - the + // path bounds the authority, so the prose '@' is never the strip point. + [ + 'https://api.example:8443/v1 - contact admin@example.com', + 'https://api.example:8443/v1 - contact admin@example.com', + ], + // #8136 repro-1 verbatim (em-dash as in the issue) also stays unchanged. + [ + 'https://api.example:8443/v1 — contact admin@example.com', + 'https://api.example:8443/v1 — contact admin@example.com', + ], + // URL-throwing shapes (invalid %, space in host) + prose email: do not + // strip the prose '@'. #8136 R2-2. + [ + 'https://api.example%/v1, contact admin@example.com', + 'https://api.example%/v1, contact admin@example.com', + ], + [ + 'https://my service/v1 - contact admin@example.com', + 'https://my service/v1 - contact admin@example.com', + ], ])('sanitizes provider base URL credentials for %s', (input, expected) => { expect(sanitizeProviderBaseUrl(input)).toBe(expected); }); diff --git a/packages/cli/src/utils/acpModelUtils.ts b/packages/cli/src/utils/acpModelUtils.ts index de14c6f0aad..cf75f67d000 100644 --- a/packages/cli/src/utils/acpModelUtils.ts +++ b/packages/cli/src/utils/acpModelUtils.ts @@ -171,51 +171,211 @@ export function sanitizeProviderBaseUrl(baseUrl: string): string { const stripAt = (at: number) => `${baseUrl.slice(0, authorityStart)}${baseUrl.slice(at + 1)}`; const authorityEnd = findAuthorityEnd(baseUrl, authorityStart); - const authorityAt = baseUrl - .slice(authorityStart, authorityEnd) - .lastIndexOf('@'); - const authorityAtIndex = - authorityAt === -1 ? -1 : authorityStart + authorityAt; + const stripPoint = findUserInfoStripPoint( + baseUrl, + authorityStart, + authorityEnd, + ); + return stripPoint === -1 ? baseUrl : stripAt(stripPoint); +} - try { - const parsed = new URL(baseUrl); - if (parsed.username || parsed.password) { - return authorityAtIndex >= authorityStart - ? stripAt(authorityAtIndex) - : baseUrl; +/** + * A URL whose authority looks like `host:port ` (e.g. + * `https://api.example:8443 - contact admin@example.com`) is misparsed by WHATWG + * as userinfo. The prose '@' would become the strip point and corrupt the + * message. Veto when the authority has a host-shape before a port colon, a + * digit port (optionally trailing punctuation), and a later '@' in prose. + * + * The host check accepts dotted OR dotless host labels (covers `ollama` as well + * as `api.example`); IPv6 bracket literals skip past the `]` to find the port + * colon. #8136 R1-7/R5-1/R5-5/R6-1. + * + * KNOWN RESIDUAL: a dotted/dotless USERNAME + digit-prefix password + space + * (`user:1234 secret@host`, `foo.bar:1234 secret@host`) is locally + * indistinguishable from `host:port ` here and is also vetoed, leaking + * the credential. These two classes cannot be separated at this veto without a + * parser oracle; the leak is a documented tradeoff pending maintainer sign-off. + * #8136 R1-2/R5-7. + */ +function isLikelyPortProseMisparse( + baseUrl: string, + authorityStart: number, + authorityEnd: number, +): boolean { + // Locate the port colon, skipping IPv6 bracket literals. + let colon = baseUrl.indexOf(':', authorityStart); + if (colon === -1 || colon > authorityEnd) { + return false; + } + if (baseUrl[authorityStart] === '[') { + const close = baseUrl.indexOf(']', authorityStart); + if (close === -1 || close >= authorityEnd) { + return false; } - return baseUrl; - } catch { - if (authorityAtIndex >= authorityStart) { - return stripAt(authorityAtIndex); + colon = baseUrl.indexOf(':', close + 1); + if (colon === -1 || colon > authorityEnd) { + return false; } - - const fallbackAt = findUnescapedUserInfoFallbackAt( - baseUrl, - authorityStart, - authorityEnd, - ); - return fallbackAt === -1 ? baseUrl : stripAt(fallbackAt); } + // A real userinfo has its '@' before the colon (`user@host:99999`); an '@' + // before the colon means userinfo, not a host:port prose shape. + const at = baseUrl.indexOf('@', authorityStart); + if (at !== -1 && at < colon) { + return false; + } + const beforeColon = baseUrl.slice(authorityStart, colon); + // Host-shape: dotted or a single dotless label (not '[' content, which the + // IPv6 branch already handled). + if (!/^[A-Za-z0-9._-]+$/.test(beforeColon)) { + return false; + } + const afterColon = baseUrl.slice(colon + 1, authorityEnd); + // The prose must contain an '@' after the port+whitespace run. + if (!afterColon.includes('@')) { + return false; + } + const wsInAfter = afterColon.search(/\s/); + if (wsInAfter === -1) { + return false; // no whitespace => no prose separator + } + const portCandidate = afterColon.slice(0, wsInAfter); + // Digit port, optionally followed by non-alphanumeric punctuation (one or + // more chars, e.g. `8443,.` `8443;` em-dash). An empty candidate + // (`ollama.local: please ...`) is also a prose shape where the ':' is the + // prose separator, not a port. #8136 R1-7/R6-1/R7-3. + return portCandidate === '' || /^\d+[^A-Za-z0-9]*$/.test(portCandidate); } -function findUnescapedUserInfoFallbackAt( +/** A clean hostname (with optional numeric port) to the end of the authority. + * Accepts Unicode host labels (IDN) in addition to ASCII. #8136 R7-10. */ +const CLEAN_HOST_AFTER = /^[A-Za-z0-9._\p{L}\p{N}\p{M}]+(:\d+)?$/u; + +/** + * Locate the userinfo terminator '@' to strip, or -1 when stripping would be + * unsafe. Scans the authority's structure rather than trusting `new URL()`'s + * userinfo report, because WHATWG misparses prose shapes (host:port + trailing + * email) as userinfo and misses Windows-domain (`DOMAIN\user:pass@`) ones. + * + * - The terminator is the LAST '@' whose following text is a clean hostname to + * the end of the authority - so a password containing '@', whitespace, tab, + * or Unicode space (`user:p@ss@host`, `user:pass word@host`, or an nbsp + * inside the password) still strips at the real terminator. + * - Prose shapes are vetoed: a host + trailing email whose first '@' is after + * whitespace with no ':' before it (`api.example - contact admin@example.com`), + * and a dotted host + numeric port + prose email (`api.example:8443 - contact + * admin@example.com`). + * - Whitespace-less prose with an embedded email (`u:p@h,see(admin@example.com)`) + * and real credentials with a trailing prose email fall back to the FIRST '@'. + * - A password containing `/ ? #` pushes the '@' past `authorityEnd` (the parser + * throws); fall back to the full-string last '@' when the authority has a ':' + * and the run between the last '/' and the candidate has no whitespace. + */ +function findUserInfoStripPoint( baseUrl: string, authorityStart: number, authorityEnd: number, ): number { - const at = baseUrl.lastIndexOf('@'); - if (at < authorityStart || authorityEnd >= at) { + const authority = baseUrl.slice(authorityStart, authorityEnd); + const firstWs = authority.search(/\s/); + const firstAt = authority.indexOf('@'); + + if (firstAt === -1) { + // No '@' in the authority. A password containing / ? # pushes the '@' past + // authorityEnd (new URL() throws). Fall back to the full-string last '@' + // when the authority has a ':' that is a real userinfo delimiter (not an + // all-digit port, e.g. `host:99999/path@domain`) and no whitespace in the + // run between the last '/' and the candidate (prose guard). #8136. + // + // KNOWN RESIDUAL: an '@' in the path (e.g. npm scoped + // `/node_modules/@qwen/pkg`) with a host:port-shaped authority before it + // is also stripped by this fallback — base has the same behavior (the + // between-run has no whitespace). #8136 R7-5. + const fullAt = baseUrl.lastIndexOf('@'); + if (fullAt >= authorityStart) { + // Locate the userinfo colon, skipping IPv6 bracket literals so `[::1]`'s + // inner colons are not mistaken for the delimiter. #8136 R6-2. + let colon = baseUrl.indexOf(':', authorityStart); + if (baseUrl[authorityStart] === '[') { + const close = baseUrl.indexOf(']', authorityStart); + if (close !== -1 && close < authorityEnd) { + colon = baseUrl.indexOf(':', close + 1); + } + } + if (colon !== -1 && colon < authorityEnd) { + const afterColon = baseUrl.slice(colon + 1, authorityEnd); + const afterWs = afterColon.search(/\s/); + const colonCandidate = + afterWs === -1 ? afterColon : afterColon.slice(0, afterWs); + if (/^\d+$/.test(colonCandidate)) { + return -1; // all-digit port, not userinfo + } + const lastSlash = baseUrl.lastIndexOf('/', fullAt); + const between = + lastSlash >= authorityStart + ? baseUrl.slice(lastSlash, fullAt) + : baseUrl.slice(authorityStart, fullAt); + if (!/\s/.test(between)) { + return fullAt; + } + } + } return -1; } - const colon = baseUrl.indexOf(':', authorityStart); - if (colon === -1 || colon > authorityEnd) { + // Prose veto: a host + trailing email (`api.example - contact + // admin@example.com`) has its first '@' AFTER the first whitespace and no + // ':' before it (no userinfo). A real credential's '@' either precedes whitespace + // (`user@host`), or has a ':' before it with no whitespace between the colon + // and '@' (`user:pass word@host` has the colon before the whitespace, so the + // slice to '@' has whitespace — but the colon still precedes '@', marking it + // userinfo-shaped). #8136 R3-6. + const atBeforeWs = firstWs === -1 || firstAt < firstWs; + // A colon before the first '@' marks userinfo only when it is a real + // userinfo delimiter, not an IPv6 bracket/port colon. An IPv6 authority + // (`[::1]:8443`) has no userinfo colon before the first '@' (its colons are + // inside the brackets or the port colon after `]`). #8136 R1-7. + const colonBeforeAt = + baseUrl[authorityStart] !== '[' && + authority.slice(0, firstAt).includes(':'); + if (!atBeforeWs && !colonBeforeAt) { + return -1; + } + // Prose veto: a host + numeric port + prose email. #8136 R2-1. + if (isLikelyPortProseMisparse(baseUrl, authorityStart, authorityEnd)) { return -1; } - const portCandidate = baseUrl.slice(colon + 1, authorityEnd); - return /^\d+$/.test(portCandidate) ? -1 : at; + // Real terminator. If the text right after the FIRST '@' starts with a DOTTED + // hostname (optionally a port) then whitespace/end, the password has no '@' + // and the first '@' is the terminator (`user:pass@host.example - contact + // admin@example.com`). Otherwise the first '@' is inside a password that + // contains '@' (`user:p@ss word@host`), and the LAST '@' with a clean + // hostname after it is the terminator. #8136 R3-5. + const afterFirstAt = authority.slice(firstAt + 1); + const firstHost = afterFirstAt.match(/^([A-Za-z0-9._-]+)(?::\d+)?(?:\s|$)/); + if (firstHost !== null && firstHost[1]!.includes('.')) { + return authorityStart + firstAt; + } + // When the first '@' is before the first whitespace, a prose email's '@' may + // still sit after the whitespace (e.g. `user:pass@ollama - contact + // admin@example.com`). Without a parser oracle this is indistinguishable from + // a password containing '@' followed by a real terminator after whitespace + // (`user:p@ss word@host`), so the loop scans the whole authority and the + // prose-shape leak is a documented residual. #8136 R5-1/R6-3. + for ( + let i = authority.lastIndexOf('@'); + i > firstAt; + i = authority.lastIndexOf('@', i - 1) + ) { + if (CLEAN_HOST_AFTER.test(authority.slice(i + 1))) { + return authorityStart + i; + } + } + // No '@' is followed by a clean hostname: whitespace-less prose with an + // embedded email (`u:p@h,see(admin@example.com)`) or real credentials with a + // trailing prose email. Strip up to the FIRST '@' to drop the userinfo while + // keeping host + prose. #8136 R4-3. + return authorityStart + firstAt; } function findAuthorityEnd(baseUrl: string, authorityStart: number): number { @@ -226,6 +386,35 @@ function findAuthorityEnd(baseUrl: string, authorityStart: number): number { if (slash !== -1) end = Math.min(end, slash); if (query !== -1) end = Math.min(end, query); if (hash !== -1) end = Math.min(end, hash); + // WHATWG treats '\' as a path separator on special schemes (http/https/ws/ + // wss/ftp/file), so it terminates the authority too - UNLESS it introduces a + // Windows `domain\user:pass@` credential shape, which is a single userinfo + // run. URL schemes are case-insensitive, so match case-insensitively. #8136 + // R4-5/R5-14. + const scheme = baseUrl.match(/^[A-Za-z][A-Za-z\d+.-]*:\/\//)?.[0] ?? ''; + if (/^(https?|wss?|ftp|file):\/\//i.test(scheme)) { + const backslash = baseUrl.indexOf('\\', authorityStart); + if (backslash !== -1 && backslash < end) { + // A Windows `domain\user:pass@` credential is a single userinfo run with + // NO whitespace between the backslash and the '@'. Bound the scan at the + // first whitespace so a later prose `a:b@c` is not mistaken for + // credentials ('/' '?' '#' already bound `end` above). #8136 R5-12. + const wsAfter = baseUrl.indexOf(' ', backslash + 1); + const scanLimit = wsAfter === -1 || wsAfter > end ? end : wsAfter; + const colonAfter = baseUrl.indexOf(':', backslash + 1); + const atAfter = baseUrl.indexOf('@', backslash + 1); + const windowsCred = + colonAfter !== -1 && + atAfter !== -1 && + colonAfter < atAfter && + colonAfter < scanLimit && + atAfter < scanLimit && + !/\s/.test(baseUrl.slice(backslash + 1, atAfter)); + if (!windowsCred) { + end = Math.min(end, backslash); + } + } + } return end; }