UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

165 lines (164 loc) • 7.39 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. import { fetchWithRetry, readBodyWithByteCap } from "../../core/common.js"; import { bareHostname, classifyNetworkAddress, classifyNetworkHostname, resolveHostnameAddresses, } from "../../core/network-policy.js"; /** Redirect budget for a single guarded request chain. */ const MAX_GUARDED_REDIRECTS = 8; export function assertWebsiteRequestUrl(rawUrl, ErrorType = Error, options) { const parsedUrl = new URL(rawUrl); if (parsedUrl.username || parsedUrl.password) { throw new ErrorType(`Refusing to fetch URL with embedded credentials: ${parsedUrl.hostname}`); } if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { throw new ErrorType(`Refusing to fetch non-http(s) URL on host: ${parsedUrl.hostname}`); } const hostname = parsedUrl.hostname.toLowerCase(); if (hostname.endsWith(".invalid")) { throw new ErrorType(`Refusing to fetch reserved invalid hostname: ${parsedUrl.hostname}`); } if (isForbiddenWebsiteHostname(hostname, options)) { throw new ErrorType(`Refusing to fetch non-public website host: ${parsedUrl.hostname}`); } } /** * Resolve-then-validate SSRF guard against DNS rebinding / private-range * bypasses. {@link assertWebsiteRequestUrl} only rejects IP-literal and * well-known-name hosts; a hostname like `private-host.example.com` that * resolves to `10.0.0.1` passes those checks and then fetch connects to the * private address. Here we resolve EVERY A/AAAA record and validate each * against the same forbidden-range rules, failing CLOSED on an empty answer or * resolver error. * * TOCTOU residual (documented, not fully closable here): Bun's `fetch` exposes * no custom `lookup`/agent hook, so we cannot pin the socket to the exact IP we * validated — a hostile resolver could return a public IP to this lookup and a * private IP microseconds later at connect time (classic rebinding). This still * removes the TRIVIAL `hostname A 10.0.0.1` bypass, which is the strongest * guarantee available without a pinned-connection fetch API. Re-run on every * redirect hop (the crawler recurses through `fetchWebsiteResponse`). */ export async function assertResolvedHostAllowed(hostname, options) { if (options?.allowPrivateHosts === true) return; const bare = bareHostname(hostname.toLowerCase()); const literalClass = classifyNetworkAddress(bare); if (literalClass !== "invalid") { if (literalClass !== "public") throw new Error(`Refusing to fetch non-public website host: ${hostname}`); return; } let addresses; try { addresses = await resolveHostnameAddresses(bare, options?.resolveHostname); } catch { throw new Error(`Refusing to fetch ${hostname}: DNS resolution failed`); } if (addresses.length === 0) { throw new Error(`Refusing to fetch ${hostname}: hostname resolved to no addresses`); } for (const address of addresses) { if (classifyNetworkAddress(address) !== "public") { throw new Error(`Refusing to fetch ${hostname}: resolves to non-public or unparseable address ${address}`); } } } function isForbiddenWebsiteHostname(hostname, options) { if (options?.allowPrivateHosts === true) return false; return classifyNetworkHostname(hostname) !== "public"; } export function isLoopbackWebsiteHostname(hostname) { return classifyNetworkHostname(hostname) === "loopback"; } function redirectInit(init, currentUrl, nextUrl) { if (new URL(currentUrl).origin === new URL(nextUrl).origin) return init; const headers = new Headers(init.headers); headers.delete("authorization"); headers.delete("cookie"); headers.delete("proxy-authorization"); return { ...init, headers }; } /** * Fetch with the full SSRF guard chain applied to the initial URL and to every * redirect hop. * * `redirect: "manual"` is essential: with the default `follow`, the platform * resolves and connects to redirect targets that were never validated, so a * public host can bounce a request into the private network. */ export async function fetchGuardedResponse(url, init, opts, redirectCount = 0) { assertWebsiteRequestUrl(url, Error, opts); await assertResolvedHostAllowed(new URL(url).hostname, opts); const response = await fetchWithRetry(url, { ...init, redirect: "manual" }, { timeout: opts.timeoutMs, retries: opts.retries ?? 1, }); if (response.status >= 300 && response.status < 400) { if (redirectCount >= MAX_GUARDED_REDIRECTS) { await response.body?.cancel().catch(() => undefined); throw new Error(`Too many redirects while fetching ${url}`); } const location = response.headers.get("location"); if (!location) { await response.body?.cancel().catch(() => undefined); throw new Error(`Redirect response from ${url} did not include a Location header`); } await response.body?.cancel().catch(() => undefined); const nextUrl = new URL(location, url).toString(); return fetchGuardedResponse(nextUrl, redirectInit(init, url, nextUrl), opts, redirectCount + 1); } return { response, finalUrl: url }; } /** * GET text from a compile-time fixed API host, rejecting redirects so request * headers (which may contain a token) can never be forwarded elsewhere. */ export async function fetchPinnedText(url, options) { const response = await fetchWithRetry(url, { headers: options.headers, signal: options.signal, redirect: "manual" }, { timeout: options.timeoutMs, retries: options.retries ?? 1 }); if (response.status >= 300 && response.status < 400) { await response.body?.cancel().catch(() => undefined); return null; } if (!response.ok) { await response.body?.cancel().catch(() => undefined); return null; } try { return await readBodyWithByteCap(response, options.byteCap, { bodyTimeoutMs: options.bodyTimeoutMs, signal: options.signal, }); } catch (error) { if (options.signal?.aborted) throw error; return null; } } /** * GET a JSON document from a PINNED, constant API host (X, Bluesky XRPC), with * a byte-capped read. Returns the parsed value, or null on any 3xx/non-2xx/ * parse failure. * * `redirect: "manual"` + rejecting 3xx is load-bearing: these endpoints have no * legitimate reason to redirect, and following one would send the next request * (bearer token included) to an unvalidated host with none of the SSRF guards * the crawl path applies. The full resolve-then-validate DNS guard is * intentionally NOT applied here because the host is a compile-time constant, * not caller-supplied — the guarded chokepoint is {@link fetchGuardedResponse}, * which the caller-supplied crawl/feed paths use instead. */ export async function fetchPinnedJson(url, options) { const body = await fetchPinnedText(url, options); if (body === null) return null; try { return JSON.parse(body); } catch { return null; } }