Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions packages/ai/src/provider-utils/localOnlyFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { isLoopbackHostname } from "./localUrl";
import { extractRawHost, isLoopbackHostname } from "./localUrl";

const MAX_REDIRECTS = 5;

Expand Down Expand Up @@ -37,17 +37,45 @@ function isRedirectResponse(res: Response): boolean {
* host (`localhost`, `127.0.0.0/8`, `::1`, or IPv4-mapped loopback). Throws a
* generic, label-prefixed Error otherwise. `context` distinguishes the
* initial URL from a redirect in the message.
*
* `rawHost`, when supplied and non-null, is the host substring extracted
* from the raw URL source BEFORE WHATWG canonicalisation (see
* `extractRawHost`). It is used for the loopback-literal check instead of
* `url.hostname` so non-standard IPv4 spellings — `0x7f.0.0.1` (hex),
* `2130706433` (uint32), `010.0.0.1` (lenient octal) — that the URL parser
* silently rewrites to `127.0.0.1` cannot bypass the gate. Callers pass
* `rawHost` for the initial URL (where the raw form is available) and omit
* it for redirect targets (where only the canonicalised URL exists; the
* canonical-hostname check there is still a tightening over no check at
* all). When `rawHost` is `null` (extractRawHost failed) or omitted, we
* fall back to `url.hostname` — the protocol/credential checks that fire
* earlier in this function will catch genuinely malformed URLs
* (e.g. `file:///...`) with the correct error message first.
*/
function assertLoopbackTarget(url: URL, label: string, context: "initial URL" | "redirect"): void {
function assertLoopbackTarget(
url: URL,
label: string,
context: "initial URL" | "redirect",
rawHost?: string | null
): void {
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`${label}: refusing ${context} to non-HTTP(S) URL.`);
}
if (url.username || url.password) {
throw new Error(`${label}: refusing ${context} with credentials.`);
}
const host = url.hostname.replace(/^\[|\]$/g, "");
if (!isLoopbackHostname(host)) {
throw new Error(`${label}: refusing ${context} to non-loopback host (${url.href}).`);
const hostForCheck = rawHost
? rawHost.replace(/^\[|\]$/g, "")
: url.hostname.replace(/^\[|\]$/g, "");
if (!isLoopbackHostname(hostForCheck)) {
// Surface the host we actually rejected so logs match the rejection
// reason. For non-standard IPv4 spellings like `0x7f.0.0.1`, `url.href`
// has already been canonicalised to `http://127.0.0.1/` which would
// make the message read as if loopback were rejected; include the raw
// host in quotes too so the literal that failed the gate is visible.
throw new Error(
`${label}: refusing ${context} to non-loopback host "${hostForCheck}" (${url.href}).`
);
}
}

Expand Down Expand Up @@ -80,7 +108,17 @@ export async function localOnlyFetch(
} catch {
throw new Error(`${label}: invalid initial URL.`);
}
assertLoopbackTarget(initialUrl, label, "initial URL");
// Pass the RAW host from the source string (not `initialUrl.hostname`) so
// WHATWG canonicalisation of non-standard IPv4 spellings — `0x7f.0.0.1`,
// `2130706433`, lenient `010.0.0.1` — cannot silently bypass the loopback
// gate by being rewritten to `127.0.0.1`. Sibling `normalizeLocalHttpUrl`
// closed the same bypass class; this mirrors it for `localOnlyFetch`.
// `extractRawHost` may return `null` for shapes without an extractable
// host (e.g. `file:///...`); `assertLoopbackTarget` falls back to
// `url.hostname` in that case and the earlier protocol check still fires
// the correct "non-HTTP(S)" error message.
const rawHost = extractRawHost(input);
assertLoopbackTarget(initialUrl, label, "initial URL", rawHost);

let current = initialUrl.href;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
Expand Down
Loading