From 045e271853748623a6a92ef53b2fac6891621d33 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Wed, 24 Jun 2026 23:21:43 +0800 Subject: [PATCH 1/9] feat(osv-advisory-source,validate-url): add SSRF defense for --osv-url flag --- src/advisory/osv-advisory-source.ts | 3 +- src/cli/args.ts | 4 + src/cli/help.ts | 3 +- src/cli/validate.ts | 5 +- src/index.ts | 2 +- src/types.ts | 2 + src/utils/validate-url.ts | 70 +++++++++++++++++ tests/osv-advisory-source.test.ts | 2 + tests/validate-url.test.ts | 112 ++++++++++++++++++++++++++++ tests/validate.test.ts | 66 ++++++++-------- 10 files changed, 235 insertions(+), 34 deletions(-) create mode 100644 src/utils/validate-url.ts create mode 100644 tests/validate-url.test.ts diff --git a/src/advisory/osv-advisory-source.ts b/src/advisory/osv-advisory-source.ts index 1195ed2..c775d40 100644 --- a/src/advisory/osv-advisory-source.ts +++ b/src/advisory/osv-advisory-source.ts @@ -46,6 +46,7 @@ export class OsvAdvisorySource implements AdvisorySource { "Content-Type": "application/json", }, body: JSON.stringify(payload), + redirect: "error", }); if (this.debugLog) { this.debugLog("OSV response", { @@ -98,7 +99,7 @@ export class OsvAdvisorySource implements AdvisorySource { headers: {}, }); } - const response = await fetch(requestUrl); + const response = await fetch(requestUrl, { redirect: "error" }); if (this.debugLog) { this.debugLog("OSV response", { method: "GET", diff --git a/src/cli/args.ts b/src/cli/args.ts index 717d5b1..2239dd4 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -253,6 +253,10 @@ export function parseArgs(argv: string[]): { options.osvUrl = arg.slice("--osv-url=".length); continue; } + if (arg === "--allow-private-osv-url") { + options.allowPrivateOsvUrl = true; + continue; + } if (arg === "--output") { options.output = argv[++i]; continue; diff --git a/src/cli/help.ts b/src/cli/help.ts index 69434ad..7059798 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -43,7 +43,8 @@ export function printHelp(): void { " --ratchet Save current findings as baseline or, if baseline exists, only fail on new findings", " --create-pr After --fix, commit changes and open a GitHub pull request (requires gh)", " --base Base branch for --create-pr (default: main)", - " --osv-url Use a custom OSV-compatible advisory endpoint", + " --osv-url Use a custom OSV-compatible advisory endpoint (HTTPS only)", + " --allow-private-osv-url Allow --osv-url to resolve to private/reserved IPs", " --ca-cert Path to a CA certificate file for corporate SSL proxies", " --debug Write verbose runtime/network diagnostics to a timestamped log file", " --verbose Show detailed output with fix plan, paths, and full table", diff --git a/src/cli/validate.ts b/src/cli/validate.ts index fff3da9..11c9603 100644 --- a/src/cli/validate.ts +++ b/src/cli/validate.ts @@ -1,7 +1,8 @@ import { validateCaCertFile } from "./config.js"; +import { validateOsvUrl } from "../utils/validate-url.js"; import type { ParsedOptions } from "../types.js"; -export function validateOptions(options: ParsedOptions): void { +export async function validateOptions(options: ParsedOptions): Promise { if ((options.offline || options.offlineDb) && options.osvUrl) { throw new Error("--offline/--offline-db cannot be used with --osv-url"); } @@ -16,6 +17,8 @@ export function validateOptions(options: ParsedOptions): void { } catch { throw new Error(`Invalid value for --osv-url: ${options.osvUrl}`); } + // SSRF defense: validate scheme, IP, and allow-private flag + await validateOsvUrl(options.osvUrl, !!options.allowPrivateOsvUrl); } if (options.fix && options.json) { diff --git a/src/index.ts b/src/index.ts index 9046083..1bebd14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -220,7 +220,7 @@ if (parsedArgs) { process.exit(exitCode); } - validateOptions(options); + await validateOptions(options); // Multi-folder mode: if no root lockfile and 2+ nested lockfiles exist, // route to dedicated multi-folder handler instead of single-lockfile scan diff --git a/src/types.ts b/src/types.ts index 7ac4165..1093724 100644 --- a/src/types.ts +++ b/src/types.ts @@ -229,6 +229,8 @@ export type ParsedOptions = { checkNetwork?: boolean; /** --rule - filter `overrides` to a single rule (OA001-OA008). */ rule?: string; + /** --allow-private-osv-url - allow --osv-url to resolve to private/reserved IPs. */ + allowPrivateOsvUrl?: boolean; }; /** diff --git a/src/utils/validate-url.ts b/src/utils/validate-url.ts new file mode 100644 index 0000000..b27d924 --- /dev/null +++ b/src/utils/validate-url.ts @@ -0,0 +1,70 @@ +import dns from "node:dns"; +import { URL } from "node:url"; + +const OFFICIAL_OSV_HOSTS = ["api.osv.dev"]; + +// RFC 1918, loopback, link-local, multicast, etc. +const PRIVATE_IP_PATTERNS = [ + /^127\./, // loopback IPv4 + /^10\./, // private Class A + /^172\.(1[6-9]|2\d|3[01])\./, // private Class B + /^192\.168\./, // private Class C + /^169\.254\./, // link-local IPv4 + /^0\./, // "this network" + /^::1$/, // IPv6 loopback + /^fc00:/, // IPv6 ULA + /^fe80:/, // IPv6 link-local + /^ff[0-9a-f]{2}:/i, // IPv6 multicast +]; + +export function isOfficialOsv(url: string): boolean { + try { + const parsed = new URL(url); + return OFFICIAL_OSV_HOSTS.includes(parsed.hostname); + } catch { + return false; + } +} + +export function isPrivateOrReservedIp(ip: string): boolean { + return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(ip)); +} + +export async function validateOsvUrl( + url: string, + allowPrivate: boolean, +): Promise { + const parsed = new URL(url); + + // Layer 1: HTTPS only + if (parsed.protocol !== "https:") { + throw new Error( + `--osv-url requires https:// scheme, got ${parsed.protocol}`, + ); + } + + // Skip IP check for official hosts + if (isOfficialOsv(url)) return; + + // Layer 2: Resolve DNS and check IP + const address = await new Promise((resolve, reject) => { + dns.lookup(parsed.hostname, (err: NodeJS.ErrnoException | null, address: string, family: number) => { + if (err) reject(err); + else resolve({ address, family }); + }); + }); + + if (isPrivateOrReservedIp(address.address)) { + if (!allowPrivate) { + throw new Error( + `--osv-url resolves to private/reserved IP (${address.address}). ` + + `Use --allow-private-osv-url for internal mirrors.`, + ); + } + // Layer 3: Explicit bypass with warning + console.warn( + `Warning: --allow-private-osv-url is set. ` + + `Requests may access internal host (${address.address}).`, + ); + } +} diff --git a/tests/osv-advisory-source.test.ts b/tests/osv-advisory-source.test.ts index 4fe69af..735a13b 100644 --- a/tests/osv-advisory-source.test.ts +++ b/tests/osv-advisory-source.test.ts @@ -57,6 +57,7 @@ describe("OsvAdvisorySource", () => { }, ], }), + redirect: "error", }); expect(results).toEqual([ @@ -111,6 +112,7 @@ describe("OsvAdvisorySource", () => { expect(fetchMock).toHaveBeenCalledWith( "https://example.test/v1/vulns/GHSA-abcd%2F1234", + { redirect: "error" }, ); expect(vuln).toMatchObject({ id: "GHSA-abcd/1234", diff --git a/tests/validate-url.test.ts b/tests/validate-url.test.ts new file mode 100644 index 0000000..efca0de --- /dev/null +++ b/tests/validate-url.test.ts @@ -0,0 +1,112 @@ +import { isOfficialOsv, isPrivateOrReservedIp, validateOsvUrl } from "../src/utils/validate-url.js"; + +describe("isOfficialOsv", () => { + it("returns true for official OSV URL", () => { + expect(isOfficialOsv("https://api.osv.dev/v1/querybatch")).toBe(true); + }); + + it("returns true for official OSV URL without path", () => { + expect(isOfficialOsv("https://api.osv.dev")).toBe(true); + }); + + it("returns false for non-official URL", () => { + expect(isOfficialOsv("https://evil.com")).toBe(false); + }); + + it("returns false for invalid URL", () => { + expect(isOfficialOsv("not-a-url")).toBe(false); + }); + + it("returns true for HTTP official host (scheme check is in validateOsvUrl)", () => { + // isOfficialOsv only checks hostname, scheme validation is in validateOsvUrl + expect(isOfficialOsv("http://api.osv.dev")).toBe(true); + }); +}); + +describe("isPrivateOrReservedIp", () => { + it("detects loopback 127.x", () => { + expect(isPrivateOrReservedIp("127.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("127.255.255.255")).toBe(true); + }); + + it("detects private Class A 10.x", () => { + expect(isPrivateOrReservedIp("10.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("10.255.255.255")).toBe(true); + }); + + it("detects private Class B 172.16-31.x", () => { + expect(isPrivateOrReservedIp("172.16.0.1")).toBe(true); + expect(isPrivateOrReservedIp("172.31.255.255")).toBe(true); + }); + + it("does not detect public IPs in 172.32-168.x range", () => { + expect(isPrivateOrReservedIp("172.32.0.1")).toBe(false); + expect(isPrivateOrReservedIp("192.0.0.1")).toBe(false); + }); + + it("detects private Class C 192.168.x", () => { + expect(isPrivateOrReservedIp("192.168.0.1")).toBe(true); + expect(isPrivateOrReservedIp("192.168.255.255")).toBe(true); + }); + + it("detects link-local 169.254.x", () => { + expect(isPrivateOrReservedIp("169.254.0.1")).toBe(true); + expect(isPrivateOrReservedIp("169.254.255.255")).toBe(true); + }); + + it("detects 'this network' 0.x", () => { + expect(isPrivateOrReservedIp("0.0.0.0")).toBe(true); + }); + + it("detects IPv6 loopback", () => { + expect(isPrivateOrReservedIp("::1")).toBe(true); + }); + + it("detects IPv6 ULA", () => { + expect(isPrivateOrReservedIp("fc00::1")).toBe(true); + }); + + it("detects IPv6 link-local", () => { + expect(isPrivateOrReservedIp("fe80::1")).toBe(true); + }); + + it("detects IPv6 multicast", () => { + expect(isPrivateOrReservedIp("ff02::1")).toBe(true); + }); + + it("does not detect public IPv4", () => { + expect(isPrivateOrReservedIp("8.8.8.8")).toBe(false); + expect(isPrivateOrReservedIp("1.1.1.1")).toBe(false); + expect(isPrivateOrReservedIp("93.184.216.34")).toBe(false); + }); + + it("does not detect public IPv6", () => { + expect(isPrivateOrReservedIp("2606:4700::1")).toBe(false); + }); +}); + +describe("validateOsvUrl", () => { + it("accepts official OSV URL without DNS check", async () => { + await expect( + validateOsvUrl("https://api.osv.dev/v1/querybatch", false), + ).resolves.toBeUndefined(); + }); + + it("rejects non-HTTPS URL", async () => { + await expect( + validateOsvUrl("http://api.osv.dev", false), + ).rejects.toThrow("--osv-url requires https:// scheme"); + }); + + it("rejects ftp URL", async () => { + await expect( + validateOsvUrl("ftp://api.osv.dev", false), + ).rejects.toThrow("--osv-url requires https:// scheme"); + }); + + it("rejects invalid URL", async () => { + await expect( + validateOsvUrl("not-a-url", false), + ).rejects.toThrow(); + }); +}); diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 174de57..919d13f 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -7,90 +7,96 @@ function opts(overrides: Partial = {}): ParsedOptions { describe("validateOptions", () => { describe("--offline / --offline-db with --osv-url", () => { - it("throws when --offline and --osv-url are combined", () => { - expect(() => validateOptions(opts({ offline: true, osvUrl: "https://example.com" }))).toThrow( + it("throws when --offline and --osv-url are combined", async () => { + await expect(validateOptions(opts({ offline: true, osvUrl: "https://api.osv.dev" }))).rejects.toThrow( "--offline/--offline-db cannot be used with --osv-url", ); }); - it("throws when --offline-db and --osv-url are combined", () => { - expect(() => validateOptions(opts({ offlineDb: "/path/to/db", osvUrl: "https://example.com" }))).toThrow( + it("throws when --offline-db and --osv-url are combined", async () => { + await expect(validateOptions(opts({ offlineDb: "/path/to/db", osvUrl: "https://api.osv.dev" }))).rejects.toThrow( "--offline/--offline-db cannot be used with --osv-url", ); }); - it("does not throw when --offline is used without --osv-url", () => { - expect(() => validateOptions(opts({ offline: true }))).not.toThrow(); + it("does not throw when --offline is used without --osv-url", async () => { + await expect(validateOptions(opts({ offline: true }))).resolves.toBeUndefined(); }); }); describe("--no-cache with --offline / --offline-db", () => { - it("throws when --no-cache and --offline are combined", () => { - expect(() => validateOptions(opts({ noCache: true, offline: true }))).toThrow( + it("throws when --no-cache and --offline are combined", async () => { + await expect(validateOptions(opts({ noCache: true, offline: true }))).rejects.toThrow( "--no-cache cannot be used with --offline or --offline-db", ); }); - it("throws when --no-cache and --offline-db are combined", () => { - expect(() => validateOptions(opts({ noCache: true, offlineDb: "/path/to/db" }))).toThrow( + it("throws when --no-cache and --offline-db are combined", async () => { + await expect(validateOptions(opts({ noCache: true, offlineDb: "/path/to/db" }))).rejects.toThrow( "--no-cache cannot be used with --offline or --offline-db", ); }); - it("does not throw when --no-cache is used without offline flags", () => { - expect(() => validateOptions(opts({ noCache: true }))).not.toThrow(); + it("does not throw when --no-cache is used without offline flags", async () => { + await expect(validateOptions(opts({ noCache: true }))).resolves.toBeUndefined(); }); }); describe("--osv-url validation", () => { - it("throws when --osv-url is not a valid URL", () => { - expect(() => validateOptions(opts({ osvUrl: "not-a-url" }))).toThrow( + it("throws when --osv-url is not a valid URL", async () => { + await expect(validateOptions(opts({ osvUrl: "not-a-url" }))).rejects.toThrow( "Invalid value for --osv-url: not-a-url", ); }); - it("does not throw when --osv-url is a valid URL", () => { - expect(() => validateOptions(opts({ osvUrl: "https://custom.osv.example.com" }))).not.toThrow(); + it("does not throw when --osv-url is official OSV URL", async () => { + await expect(validateOptions(opts({ osvUrl: "https://api.osv.dev" }))).resolves.toBeUndefined(); + }); + + it("rejects non-HTTPS --osv-url", async () => { + await expect(validateOptions(opts({ osvUrl: "http://api.osv.dev" }))).rejects.toThrow( + "--osv-url requires https:// scheme", + ); }); }); describe("--fix with --json", () => { - it("throws when --fix and --json are combined", () => { - expect(() => validateOptions(opts({ fix: true, json: true }))).toThrow( + it("throws when --fix and --json are combined", async () => { + await expect(validateOptions(opts({ fix: true, json: true }))).rejects.toThrow( "--fix cannot be used with --json", ); }); - it("does not throw when --fix is used without --json", () => { - expect(() => validateOptions(opts({ fix: true }))).not.toThrow(); + it("does not throw when --fix is used without --json", async () => { + await expect(validateOptions(opts({ fix: true }))).resolves.toBeUndefined(); }); }); describe("--report with --json", () => { - it("throws when --report and --json are combined", () => { - expect(() => validateOptions(opts({ report: "report.html", json: true }))).toThrow( + it("throws when --report and --json are combined", async () => { + await expect(validateOptions(opts({ report: "report.html", json: true }))).rejects.toThrow( "--report cannot be used with --json", ); }); - it("does not throw when --report is used without --json", () => { - expect(() => validateOptions(opts({ report: "report.html" }))).not.toThrow(); + it("does not throw when --report is used without --json", async () => { + await expect(validateOptions(opts({ report: "report.html" }))).resolves.toBeUndefined(); }); }); describe("--ca-cert validation", () => { - it("throws with a --ca-cert: prefix when the cert file does not exist or is invalid", () => { - expect(() => validateOptions(opts({ caCert: "invalid-fake-cert.pem" }))).toThrow( + it("throws with a --ca-cert: prefix when the cert file does not exist or is invalid", async () => { + await expect(validateOptions(opts({ caCert: "invalid-fake-cert.pem" }))).rejects.toThrow( "--ca-cert:" ); }); - it("does not throw when --ca-cert is not set", () => { - expect(() => validateOptions(opts({}))).not.toThrow(); + it("does not throw when --ca-cert is not set", async () => { + await expect(validateOptions(opts({}))).resolves.toBeUndefined(); }); }); - it("does not throw for a valid set of options", () => { - expect(() => validateOptions(opts({ fix: true, verbose: true }))).not.toThrow(); + it("does not throw for a valid set of options", async () => { + await expect(validateOptions(opts({ fix: true, verbose: true }))).resolves.toBeUndefined(); }); }); From 1abfb8b6ff7b33ea8e4f9fe98cd998818bfabf37 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Wed, 24 Jun 2026 23:24:27 +0800 Subject: [PATCH 2/9] docs: add --allow-private-osv-url flag documentation --- README.md | 3 +++ website/docs/cli-reference.md | 5 +++- website/docs/corporate-proxy.md | 34 +++++++++++++++++++++++++ website/docs/offline-advisory-db.md | 8 ++++++ website/docs/security-assurance-case.md | 2 +- 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8d78333..1d409a7 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,9 @@ cve-lite /path/to/project --offline-db /path/to/advisories.db # Use a custom advisory endpoint cve-lite /path/to/project --osv-url https://security.company.internal/osv +# Allow custom endpoint to resolve to private/reserved IPs (for internal mirrors) +cve-lite /path/to/project --osv-url https://internal-mirror.company.local/osv --allow-private-osv-url + # Show version cve-lite --version diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index 3781621..6db6c7f 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -63,7 +63,10 @@ See [Offline Advisory DB](./offline-advisory-db.md) for the full offline workflo | Flag | Default | Description | Example | |---|---|---|---| | `--ca-cert=` | - | Path to a PEM CA certificate file for corporate SSL inspection proxies | `cve-lite . --ca-cert ~/corp-ca.crt` | -| `--osv-url=` | OSV API | Use a custom OSV-compatible endpoint instead of the public API | `cve-lite . --osv-url https://osv.example.com` | +| `--osv-url=` | OSV API | Use a custom OSV-compatible endpoint instead of the public API (HTTPS only, public IPs only by default) | `cve-lite . --osv-url https://osv.example.com` | +| `--allow-private-osv-url` | off | Allow `--osv-url` to resolve to private/reserved IPs (RFC 1918, loopback, link-local) for internal mirrors | `cve-lite . --osv-url https://internal-mirror.local/osv --allow-private-osv-url` | + +**Security note:** The `--osv-url` flag enforces HTTPS and blocks private/reserved IP ranges by default to prevent SSRF attacks. Use `--allow-private-osv-url` only when connecting to trusted internal mirrors. For networks with SSL inspection, save the certificate path once so you do not need to pass the flag on every scan: diff --git a/website/docs/corporate-proxy.md b/website/docs/corporate-proxy.md index 0bb1216..5bea6f9 100644 --- a/website/docs/corporate-proxy.md +++ b/website/docs/corporate-proxy.md @@ -83,6 +83,40 @@ The certificate file must be in PEM format - a plain text file starting with `-- --- +## Using a custom OSV endpoint + +If your organization hosts an internal OSV-compatible endpoint, you can point CVE Lite CLI to it with `--osv-url`: + +```bash +cve-lite . --osv-url https://internal-osv.company.local +``` + +### SSRF protection + +By default, `--osv-url` enforces: +- **HTTPS only** — HTTP URLs are rejected +- **Public IPs only** — private/reserved IP ranges (RFC 1918, loopback, link-local) are blocked + +This prevents Server-Side Request Forgery (SSRF) attacks where an attacker could redirect requests to internal services. + +### Using internal mirrors + +If your internal OSV endpoint resolves to a private IP address, you must explicitly allow it with `--allow-private-osv-url`: + +```bash +cve-lite . --osv-url https://internal-osv.company.local --allow-private-osv-url +``` + +**Security warning:** Only use `--allow-private-osv-url` with trusted internal mirrors. This bypasses the SSRF protection and allows requests to private network addresses. + +You can also save the custom endpoint to your config: + +```bash +cve-lite config set osv-url https://internal-osv.company.local +``` + +--- + ## Advisory sync over a proxy The same certificate applies when syncing the offline advisory DB: diff --git a/website/docs/offline-advisory-db.md b/website/docs/offline-advisory-db.md index 5dd4651..74ac36d 100644 --- a/website/docs/offline-advisory-db.md +++ b/website/docs/offline-advisory-db.md @@ -88,6 +88,14 @@ Use an internal proxy or mirror for the advisory API: cve-lite /path/to/project --osv-url https://security.company.internal/osv ``` +If your internal endpoint resolves to a private IP address, you must explicitly allow it: + +```bash +cve-lite /path/to/project --osv-url https://security.company.internal/osv --allow-private-osv-url +``` + +**Note:** By default, `--osv-url` enforces HTTPS and blocks private/reserved IP ranges to prevent SSRF attacks. Use `--allow-private-osv-url` only with trusted internal mirrors. + --- ## Advisory DB freshness diff --git a/website/docs/security-assurance-case.md b/website/docs/security-assurance-case.md index 789b139..ac4d718 100644 --- a/website/docs/security-assurance-case.md +++ b/website/docs/security-assurance-case.md @@ -121,7 +121,7 @@ Mapped against the [OWASP Top 10 (2021)](https://owasp.org/Top10/) — the categ | A07: Identification and Authentication Failures | Not applicable | The CLI has no authentication surface. | | A08: Software and Data Integrity Failures | Countered | Release tarballs are signed via Sigstore Artifact Attestations. Git tags are GPG-signed by the project lead. Withdrawn OSV advisories are filtered at ingest. Sync replaces the advisory database atomically rather than partially updating it. | | A09: Security Logging and Monitoring Failures | Partially countered | CodeQL alerts on every push, the self-scan workflow flags new advisories in the project's own dependencies on every push, and the GitHub release workflow emits build provenance. The project does not centrally log scan invocations because none happen on project-controlled infrastructure — scans run on each user's local machine. | -| A10: Server-Side Request Forgery | Countered | The CLI only makes outbound HTTP requests to fixed, hardcoded endpoints: `api.osv.dev`, `storage.googleapis.com/osv-vulnerabilities/...`, and `registry.npmjs.org`. No user-controlled URLs are ever used as request targets. | +| A10: Server-Side Request Forgery | Countered | The CLI only makes outbound HTTP requests to fixed, hardcoded endpoints: `api.osv.dev`, `storage.googleapis.com/osv-vulnerabilities/...`, and `registry.npmjs.org`. The optional `--osv-url` flag enforces HTTPS and blocks private/reserved IP ranges (RFC 1918, loopback, link-local) by default to prevent SSRF attacks. Using `--allow-private-osv-url` bypasses this protection and should only be used with trusted internal mirrors. | ## Limitations and explicit non-goals From adbdd428d18136fd60cf2b0e3e6846d59567a3d6 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Wed, 24 Jun 2026 23:38:13 +0800 Subject: [PATCH 3/9] docs(corporate-proxy): remove non-existent config set osv-url command --- website/docs/corporate-proxy.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/website/docs/corporate-proxy.md b/website/docs/corporate-proxy.md index 5bea6f9..b8943af 100644 --- a/website/docs/corporate-proxy.md +++ b/website/docs/corporate-proxy.md @@ -109,12 +109,6 @@ cve-lite . --osv-url https://internal-osv.company.local --allow-private-osv-url **Security warning:** Only use `--allow-private-osv-url` with trusted internal mirrors. This bypasses the SSRF protection and allows requests to private network addresses. -You can also save the custom endpoint to your config: - -```bash -cve-lite config set osv-url https://internal-osv.company.local -``` - --- ## Advisory sync over a proxy From d4774c444ad6de9c705885c4e6835026dbb9a427 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Wed, 24 Jun 2026 23:44:15 +0800 Subject: [PATCH 4/9] fix(validate-url): check all DNS results and expand private IP coverage - Use dns.lookup with { all: true } to check ALL resolved addresses - Reject if ANY resolved address is private/reserved - Expand PRIVATE_IP_PATTERNS to cover: - CGNAT (100.64.0.0/10) - IETF Protocol (192.0.0.0/24) - Documentation addresses (TEST-NET-1/2/3) - Benchmark (198.18.0.0/15) - Reserved (240.0.0.0/4) - Broadcast (255.255.255.255) - IPv6 unspecified (::) - IPv6 documentation (2001:db8::/32) - IPv6 NAT64 (64:ff9b::/96) - IPv6 discard (100::/64) - IPv6 Teredo (2001::/32) - IPv6 benchmark (2001:2::/48) - Add IPv4-mapped IPv6 address detection (::ffff:127.0.0.1) - Add comprehensive DNS resolution tests --- src/utils/validate-url.ts | 82 ++++++++++++----- tests/create-pr.test.ts | 8 +- tests/validate-url.test.ts | 178 ++++++++++++++++++++++++++++++++++++- 3 files changed, 243 insertions(+), 25 deletions(-) diff --git a/src/utils/validate-url.ts b/src/utils/validate-url.ts index b27d924..efd3c79 100644 --- a/src/utils/validate-url.ts +++ b/src/utils/validate-url.ts @@ -3,20 +3,49 @@ import { URL } from "node:url"; const OFFICIAL_OSV_HOSTS = ["api.osv.dev"]; -// RFC 1918, loopback, link-local, multicast, etc. +// RFC 1918, loopback, link-local, multicast, and other special-purpose addresses +// See: https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml const PRIVATE_IP_PATTERNS = [ - /^127\./, // loopback IPv4 - /^10\./, // private Class A - /^172\.(1[6-9]|2\d|3[01])\./, // private Class B - /^192\.168\./, // private Class C - /^169\.254\./, // link-local IPv4 - /^0\./, // "this network" - /^::1$/, // IPv6 loopback - /^fc00:/, // IPv6 ULA - /^fe80:/, // IPv6 link-local - /^ff[0-9a-f]{2}:/i, // IPv6 multicast + // IPv4 special-purpose addresses + /^127\./, // loopback (127.0.0.0/8) + /^10\./, // private Class A (10.0.0.0/8) + /^172\.(1[6-9]|2\d|3[01])\./, // private Class B (172.16.0.0/12) + /^192\.168\./, // private Class C (192.168.0.0/16) + /^169\.254\./, // link-local (169.254.0.0/16) + /^0\./, // "this network" (0.0.0.0/8) + /^100\.(6[4-9]|[7-9]\d|1[0-2][0-7])\./, // CGNAT (100.64.0.0/10) + /^192\.0\.0\./, // IETF Protocol (192.0.0.0/24) + /^192\.0\.2\./, // documentation (TEST-NET-1) + /^198\.51\.100\./, // documentation (TEST-NET-2) + /^203\.0\.113\./, // documentation (TEST-NET-3) + /^198\.1[8-9]\./, // benchmark (198.18.0.0/15) + /^(24\d|25[0-5])\./, // reserved (240.0.0.0/4) + /^255\.255\.255\.255$/, // broadcast + + // IPv6 special-purpose addresses + /^::$/, // unspecified (::/128) + /^::1$/, // loopback (::1/128) + /^::ffff:(?:255\.255\.255\.255|0\.0\.0\.0)$/, // IPv4-mapped broadcast/unspecified + /^f[cd][0-9a-f]{2}:/i, // ULA (fc00::/7) + /^fe[89ab][0-9a-f]{0,2}:/i, // link-local (fe80::/10) + /^ff[0-9a-f]{2}:/i, // multicast (ff00::/8) + /^2001:db8:/, // documentation (2001:db8::/32) + /^64:ff9b::/, // NAT64 (64:ff9b::/96) + /^100::/, // discard (100::/64) + /^2001::/, // Teredo (2001::/32) + /^2001:2::/, // benchmark (2001:2::/48) ]; +// IPv4-mapped IPv6 prefix +const IPV4_MAPPED_PREFIX = "::ffff:"; + +function isIPv4MappedIPv6(ip: string): string | null { + if (ip.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)) { + return ip.slice(IPV4_MAPPED_PREFIX.length); + } + return null; +} + export function isOfficialOsv(url: string): boolean { try { const parsed = new URL(url); @@ -27,6 +56,11 @@ export function isOfficialOsv(url: string): boolean { } export function isPrivateOrReservedIp(ip: string): boolean { + // Check for IPv4-mapped IPv6 address (e.g., ::ffff:127.0.0.1) + const ipv4 = isIPv4MappedIPv6(ip); + if (ipv4 !== null) { + return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(ipv4)); + } return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(ip)); } @@ -46,25 +80,33 @@ export async function validateOsvUrl( // Skip IP check for official hosts if (isOfficialOsv(url)) return; - // Layer 2: Resolve DNS and check IP - const address = await new Promise((resolve, reject) => { - dns.lookup(parsed.hostname, (err: NodeJS.ErrnoException | null, address: string, family: number) => { - if (err) reject(err); - else resolve({ address, family }); - }); + // Layer 2: Resolve DNS and check ALL addresses + const addresses = await new Promise((resolve, reject) => { + dns.lookup( + parsed.hostname, + { all: true }, + (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => { + if (err) reject(err); + else resolve(addresses); + }, + ); }); - if (isPrivateOrReservedIp(address.address)) { + // Check ALL resolved addresses - reject if ANY is private/reserved + const privateAddresses = addresses.filter(addr => isPrivateOrReservedIp(addr.address)); + + if (privateAddresses.length > 0) { + const ipList = privateAddresses.map(a => a.address).join(", "); if (!allowPrivate) { throw new Error( - `--osv-url resolves to private/reserved IP (${address.address}). ` + + `--osv-url resolves to private/reserved IP (${ipList}). ` + `Use --allow-private-osv-url for internal mirrors.`, ); } // Layer 3: Explicit bypass with warning console.warn( `Warning: --allow-private-osv-url is set. ` + - `Requests may access internal host (${address.address}).`, + `Requests may access internal host (${ipList}).`, ); } } diff --git a/tests/create-pr.test.ts b/tests/create-pr.test.ts index 940975f..ed79a7e 100644 --- a/tests/create-pr.test.ts +++ b/tests/create-pr.test.ts @@ -141,9 +141,9 @@ describe("create-pr CLI options", () => { expect(result.options.prBase).toBe("develop"); }); - it("requires --fix for --create-pr", () => { - expect(() => validateOptions({ failOn: "critical", batchSize: "100", searchDepth: "4", createPr: true })).toThrow( - "--create-pr requires --fix", - ); + it("requires --fix for --create-pr", async () => { + await expect( + validateOptions({ failOn: "critical", batchSize: "100", searchDepth: "4", createPr: true }), + ).rejects.toThrow("--create-pr requires --fix"); }); }); diff --git a/tests/validate-url.test.ts b/tests/validate-url.test.ts index efca0de..1f32eb1 100644 --- a/tests/validate-url.test.ts +++ b/tests/validate-url.test.ts @@ -1,3 +1,5 @@ +import { jest } from "@jest/globals"; +import dns from "node:dns"; import { isOfficialOsv, isPrivateOrReservedIp, validateOsvUrl } from "../src/utils/validate-url.js"; describe("isOfficialOsv", () => { @@ -41,7 +43,6 @@ describe("isPrivateOrReservedIp", () => { it("does not detect public IPs in 172.32-168.x range", () => { expect(isPrivateOrReservedIp("172.32.0.1")).toBe(false); - expect(isPrivateOrReservedIp("192.0.0.1")).toBe(false); }); it("detects private Class C 192.168.x", () => { @@ -58,12 +59,49 @@ describe("isPrivateOrReservedIp", () => { expect(isPrivateOrReservedIp("0.0.0.0")).toBe(true); }); + it("detects CGNAT 100.64.x.x", () => { + expect(isPrivateOrReservedIp("100.64.0.1")).toBe(true); + expect(isPrivateOrReservedIp("100.127.255.255")).toBe(true); + expect(isPrivateOrReservedIp("100.63.0.1")).toBe(false); + expect(isPrivateOrReservedIp("100.128.0.1")).toBe(false); + }); + + it("detects documentation addresses", () => { + expect(isPrivateOrReservedIp("192.0.2.1")).toBe(true); + expect(isPrivateOrReservedIp("198.51.100.1")).toBe(true); + expect(isPrivateOrReservedIp("203.0.113.1")).toBe(true); + }); + + it("detects IETF protocol 192.0.0.x", () => { + expect(isPrivateOrReservedIp("192.0.0.1")).toBe(true); + }); + + it("detects benchmark 198.18.x.x", () => { + expect(isPrivateOrReservedIp("198.18.0.1")).toBe(true); + expect(isPrivateOrReservedIp("198.19.255.255")).toBe(true); + }); + + it("detects reserved 240.0.0.0/4 range", () => { + expect(isPrivateOrReservedIp("240.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("241.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("255.0.0.1")).toBe(true); + }); + + it("detects broadcast 255.255.255.255", () => { + expect(isPrivateOrReservedIp("255.255.255.255")).toBe(true); + }); + it("detects IPv6 loopback", () => { expect(isPrivateOrReservedIp("::1")).toBe(true); }); + it("detects IPv6 unspecified", () => { + expect(isPrivateOrReservedIp("::")).toBe(true); + }); + it("detects IPv6 ULA", () => { expect(isPrivateOrReservedIp("fc00::1")).toBe(true); + expect(isPrivateOrReservedIp("fd00::1")).toBe(true); }); it("detects IPv6 link-local", () => { @@ -74,6 +112,17 @@ describe("isPrivateOrReservedIp", () => { expect(isPrivateOrReservedIp("ff02::1")).toBe(true); }); + it("detects IPv6 documentation", () => { + expect(isPrivateOrReservedIp("2001:db8::1")).toBe(true); + }); + + it("detects IPv4-mapped IPv6 addresses", () => { + expect(isPrivateOrReservedIp("::ffff:127.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("::ffff:10.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("::ffff:192.168.1.1")).toBe(true); + expect(isPrivateOrReservedIp("::ffff:8.8.8.8")).toBe(false); + }); + it("does not detect public IPv4", () => { expect(isPrivateOrReservedIp("8.8.8.8")).toBe(false); expect(isPrivateOrReservedIp("1.1.1.1")).toBe(false); @@ -86,6 +135,66 @@ describe("isPrivateOrReservedIp", () => { }); describe("validateOsvUrl", () => { + let originalLookup: typeof dns.lookup; + + beforeEach(() => { + originalLookup = dns.lookup; + }); + + afterEach(() => { + dns.lookup = originalLookup; + }); + + function mockDnsLookup(hostname: string, address: string, family: number = 4) { + (dns as any).lookup = ( + host: string, + options: dns.LookupOptions | ((err: NodeJS.ErrnoException | null, address: string, family: number) => void), + callback?: (err: NodeJS.ErrnoException | null, address: string, family: number) => void, + ) => { + // Handle the { all: true } overload + if (typeof options === "object" && options.all) { + const cb = callback!; + if (host === hostname) { + cb(null, [{ address, family }] as any, 0); + } else { + cb(new Error("ENOTFOUND"), [], 0); + } + } else { + const cb = typeof options === "function" ? options : callback!; + if (host === hostname) { + cb(null, address, family); + } else { + cb(new Error("ENOTFOUND"), "", 0); + } + } + }; + } + + function mockDnsLookupAll(hostname: string, addresses: Array<{ address: string; family: number }>) { + (dns as any).lookup = ( + host: string, + options: dns.LookupOptions | ((err: NodeJS.ErrnoException | null, address: string, family: number) => void), + callback?: (err: NodeJS.ErrnoException | null, address: string, family: number) => void, + ) => { + // Handle the { all: true } overload + if (typeof options === "object" && options.all) { + const cb = callback!; + if (host === hostname) { + cb(null, addresses as any, 0); + } else { + cb(new Error("ENOTFOUND"), [], 0); + } + } else { + const cb = typeof options === "function" ? options : callback!; + if (host === hostname && addresses.length > 0) { + cb(null, addresses[0].address, addresses[0].family); + } else { + cb(new Error("ENOTFOUND"), "", 0); + } + } + }; + } + it("accepts official OSV URL without DNS check", async () => { await expect( validateOsvUrl("https://api.osv.dev/v1/querybatch", false), @@ -109,4 +218,71 @@ describe("validateOsvUrl", () => { validateOsvUrl("not-a-url", false), ).rejects.toThrow(); }); + + it("rejects custom host resolving to loopback", async () => { + mockDnsLookup("evil.test", "127.0.0.1"); + + await expect( + validateOsvUrl("https://evil.test", false), + ).rejects.toThrow("private/reserved IP"); + }); + + it("rejects custom host resolving to private IP", async () => { + mockDnsLookup("internal.test", "10.0.0.10"); + + await expect( + validateOsvUrl("https://internal.test", false), + ).rejects.toThrow("private/reserved IP"); + }); + + it("allows private IP only with explicit flag", async () => { + mockDnsLookup("internal.test", "10.0.0.10"); + + await expect( + validateOsvUrl("https://internal.test", true), + ).resolves.toBeUndefined(); + }); + + it("rejects if any resolved address is private (multiple A records)", async () => { + mockDnsLookupAll("mixed.test", [ + { address: "93.184.216.34", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]); + + await expect( + validateOsvUrl("https://mixed.test", false), + ).rejects.toThrow("private/reserved IP"); + }); + + it("shows all private addresses in error message", async () => { + mockDnsLookupAll("multi-private.test", [ + { address: "10.0.0.1", family: 4 }, + { address: "192.168.1.1", family: 4 }, + ]); + + await expect( + validateOsvUrl("https://multi-private.test", false), + ).rejects.toThrow("10.0.0.1, 192.168.1.1"); + }); + + it("allows public IPs", async () => { + mockDnsLookup("public.test", "93.184.216.34"); + + await expect( + validateOsvUrl("https://public.test", false), + ).resolves.toBeUndefined(); + }); + + it("allows private IP with warning when flag is set", async () => { + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + mockDnsLookup("internal.test", "10.0.0.10"); + + await expect( + validateOsvUrl("https://internal.test", true), + ).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("--allow-private-osv-url is set"), + ); + consoleSpy.mockRestore(); + }); }); From fee78304e441ead647f4f9f811e3ff165913e173 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 25 Jun 2026 15:48:46 +0800 Subject: [PATCH 5/9] fix(ssrf-defense): address PR #751 review feedback from sonukapoor - Fix CGNAT regex gap: second-octets 108,109,118,119 now blocked - Add IPv4 multicast pattern (224.0.0.0/4) to private IP check - Guard --allow-private-osv-url without --osv-url with clear error - Replace console.warn with return-value warning to avoid stderr bleed in --json mode - Add test cases for CGNAT edge cases, multicast, and allowPrivate guard --- src/cli/validate.ts | 11 +++++++++-- src/index.ts | 3 ++- src/utils/validate-url.ts | 15 +++++++++------ tests/validate-url.test.ts | 30 ++++++++++++++++++------------ tests/validate.test.ts | 6 ++++++ 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/cli/validate.ts b/src/cli/validate.ts index 11c9603..d05c0ae 100644 --- a/src/cli/validate.ts +++ b/src/cli/validate.ts @@ -2,7 +2,11 @@ import { validateCaCertFile } from "./config.js"; import { validateOsvUrl } from "../utils/validate-url.js"; import type { ParsedOptions } from "../types.js"; -export async function validateOptions(options: ParsedOptions): Promise { +export async function validateOptions(options: ParsedOptions): Promise { + if (options.allowPrivateOsvUrl && !options.osvUrl) { + throw new Error("--allow-private-osv-url requires --osv-url"); + } + if ((options.offline || options.offlineDb) && options.osvUrl) { throw new Error("--offline/--offline-db cannot be used with --osv-url"); } @@ -18,7 +22,8 @@ export async function validateOptions(options: ParsedOptions): Promise { throw new Error(`Invalid value for --osv-url: ${options.osvUrl}`); } // SSRF defense: validate scheme, IP, and allow-private flag - await validateOsvUrl(options.osvUrl, !!options.allowPrivateOsvUrl); + const ssrfWarning = await validateOsvUrl(options.osvUrl, !!options.allowPrivateOsvUrl); + if (ssrfWarning) return ssrfWarning; } if (options.fix && options.json) { @@ -48,4 +53,6 @@ export async function validateOptions(options: ParsedOptions): Promise { throw new Error(`--ca-cert: ${err instanceof Error ? err.message : String(err)}`); } } + + return undefined; } diff --git a/src/index.ts b/src/index.ts index 1bebd14..9953c1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -220,7 +220,8 @@ if (parsedArgs) { process.exit(exitCode); } - await validateOptions(options); + const validationWarning = await validateOptions(options); + if (validationWarning) logWarn(validationWarning, options); // Multi-folder mode: if no root lockfile and 2+ nested lockfiles exist, // route to dedicated multi-folder handler instead of single-lockfile scan diff --git a/src/utils/validate-url.ts b/src/utils/validate-url.ts index efd3c79..2c1d740 100644 --- a/src/utils/validate-url.ts +++ b/src/utils/validate-url.ts @@ -13,12 +13,13 @@ const PRIVATE_IP_PATTERNS = [ /^192\.168\./, // private Class C (192.168.0.0/16) /^169\.254\./, // link-local (169.254.0.0/16) /^0\./, // "this network" (0.0.0.0/8) - /^100\.(6[4-9]|[7-9]\d|1[0-2][0-7])\./, // CGNAT (100.64.0.0/10) + /^100\.(6[4-9]|[7-9]\d|1(?:[01]\d|2[0-7]))\./, // CGNAT (100.64.0.0/10) /^192\.0\.0\./, // IETF Protocol (192.0.0.0/24) /^192\.0\.2\./, // documentation (TEST-NET-1) /^198\.51\.100\./, // documentation (TEST-NET-2) /^203\.0\.113\./, // documentation (TEST-NET-3) /^198\.1[8-9]\./, // benchmark (198.18.0.0/15) + /^(22[4-9]|23\d)\./, // multicast (224.0.0.0/4) /^(24\d|25[0-5])\./, // reserved (240.0.0.0/4) /^255\.255\.255\.255$/, // broadcast @@ -67,7 +68,7 @@ export function isPrivateOrReservedIp(ip: string): boolean { export async function validateOsvUrl( url: string, allowPrivate: boolean, -): Promise { +): Promise { const parsed = new URL(url); // Layer 1: HTTPS only @@ -78,7 +79,7 @@ export async function validateOsvUrl( } // Skip IP check for official hosts - if (isOfficialOsv(url)) return; + if (isOfficialOsv(url)) return undefined; // Layer 2: Resolve DNS and check ALL addresses const addresses = await new Promise((resolve, reject) => { @@ -103,10 +104,12 @@ export async function validateOsvUrl( `Use --allow-private-osv-url for internal mirrors.`, ); } - // Layer 3: Explicit bypass with warning - console.warn( + // Layer 3: Explicit bypass — return warning for caller to emit + return ( `Warning: --allow-private-osv-url is set. ` + - `Requests may access internal host (${ipList}).`, + `Requests may access internal host (${ipList}).` ); } + + return undefined; } diff --git a/tests/validate-url.test.ts b/tests/validate-url.test.ts index 1f32eb1..a577bb6 100644 --- a/tests/validate-url.test.ts +++ b/tests/validate-url.test.ts @@ -1,4 +1,3 @@ -import { jest } from "@jest/globals"; import dns from "node:dns"; import { isOfficialOsv, isPrivateOrReservedIp, validateOsvUrl } from "../src/utils/validate-url.js"; @@ -66,6 +65,13 @@ describe("isPrivateOrReservedIp", () => { expect(isPrivateOrReservedIp("100.128.0.1")).toBe(false); }); + it("detects CGNAT edge cases that were previously missed", () => { + expect(isPrivateOrReservedIp("100.108.0.1")).toBe(true); + expect(isPrivateOrReservedIp("100.109.0.1")).toBe(true); + expect(isPrivateOrReservedIp("100.118.0.1")).toBe(true); + expect(isPrivateOrReservedIp("100.119.0.1")).toBe(true); + }); + it("detects documentation addresses", () => { expect(isPrivateOrReservedIp("192.0.2.1")).toBe(true); expect(isPrivateOrReservedIp("198.51.100.1")).toBe(true); @@ -91,6 +97,12 @@ describe("isPrivateOrReservedIp", () => { expect(isPrivateOrReservedIp("255.255.255.255")).toBe(true); }); + it("detects multicast 224-239.x.x.x", () => { + expect(isPrivateOrReservedIp("224.0.0.1")).toBe(true); + expect(isPrivateOrReservedIp("239.255.255.255")).toBe(true); + expect(isPrivateOrReservedIp("223.255.255.255")).toBe(false); + }); + it("detects IPv6 loopback", () => { expect(isPrivateOrReservedIp("::1")).toBe(true); }); @@ -238,9 +250,8 @@ describe("validateOsvUrl", () => { it("allows private IP only with explicit flag", async () => { mockDnsLookup("internal.test", "10.0.0.10"); - await expect( - validateOsvUrl("https://internal.test", true), - ).resolves.toBeUndefined(); + const warning = await validateOsvUrl("https://internal.test", true); + expect(warning).toContain("--allow-private-osv-url is set"); }); it("rejects if any resolved address is private (multiple A records)", async () => { @@ -274,15 +285,10 @@ describe("validateOsvUrl", () => { }); it("allows private IP with warning when flag is set", async () => { - const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); mockDnsLookup("internal.test", "10.0.0.10"); - await expect( - validateOsvUrl("https://internal.test", true), - ).resolves.toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("--allow-private-osv-url is set"), - ); - consoleSpy.mockRestore(); + const warning = await validateOsvUrl("https://internal.test", true); + expect(warning).toContain("--allow-private-osv-url is set"); + expect(warning).toContain("10.0.0.10"); }); }); diff --git a/tests/validate.test.ts b/tests/validate.test.ts index 919d13f..be88ea1 100644 --- a/tests/validate.test.ts +++ b/tests/validate.test.ts @@ -58,6 +58,12 @@ describe("validateOptions", () => { "--osv-url requires https:// scheme", ); }); + + it("throws when --allow-private-osv-url is used without --osv-url", async () => { + await expect(validateOptions(opts({ allowPrivateOsvUrl: true }))).rejects.toThrow( + "--allow-private-osv-url requires --osv-url", + ); + }); }); describe("--fix with --json", () => { From e147c1ab5633de52b3e695784826da5b0a078cd6 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 25 Jun 2026 15:58:14 +0800 Subject: [PATCH 6/9] fix(ssrf-defense): SSRF warning no longer short-circuits option validation - Store ssrfWarning instead of returning early, so all subsequent guards (--fix --json, --create-pr, --report --json, --ca-cert) still run when --allow-private-osv-url is set - Add 5 regression tests covering invalid flag combos with private --osv-url --- src/cli/validate.ts | 6 ++-- tests/validate.test.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/cli/validate.ts b/src/cli/validate.ts index d05c0ae..787004b 100644 --- a/src/cli/validate.ts +++ b/src/cli/validate.ts @@ -15,6 +15,7 @@ export async function validateOptions(options: ParsedOptions): Promise { it("does not throw for a valid set of options", async () => { await expect(validateOptions(opts({ fix: true, verbose: true }))).resolves.toBeUndefined(); }); + + describe("SSRF warning does not short-circuit validation", () => { + let originalLookup: typeof dns.lookup; + + beforeEach(() => { + originalLookup = dns.lookup; + }); + + afterEach(() => { + dns.lookup = originalLookup; + }); + + function mockDnsLookupPrivate(hostname: string) { + (dns as any).lookup = ( + host: string, + options: dns.LookupOptions | ((err: NodeJS.ErrnoException | null, address: string, family: number) => void), + callback?: (err: NodeJS.ErrnoException | null, address: string, family: number) => void, + ) => { + if (typeof options === "object" && options.all) { + const cb = callback!; + if (host === hostname) { + cb(null, [{ address: "10.0.0.1", family: 4 }] as any, 0); + } else { + cb(new Error("ENOTFOUND"), [], 0); + } + } else { + const cb = typeof options === "function" ? options : callback!; + if (host === hostname) { + cb(null, "10.0.0.1", 4); + } else { + cb(new Error("ENOTFOUND"), "", 0); + } + } + }; + } + + it("still throws --fix --json when --allow-private-osv-url is set", async () => { + mockDnsLookupPrivate("private.test"); + await expect( + validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true, fix: true, json: true })), + ).rejects.toThrow("--fix cannot be used with --json"); + }); + + it("still throws --create-pr without --fix when --allow-private-osv-url is set", async () => { + mockDnsLookupPrivate("private.test"); + await expect( + validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true, createPr: true })), + ).rejects.toThrow("--create-pr requires --fix"); + }); + + it("still throws --report --json when --allow-private-osv-url is set", async () => { + mockDnsLookupPrivate("private.test"); + await expect( + validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true, report: "report.html", json: true })), + ).rejects.toThrow("--report cannot be used with --json"); + }); + + it("still throws bad --ca-cert when --allow-private-osv-url is set", async () => { + mockDnsLookupPrivate("private.test"); + await expect( + validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true, caCert: "nonexistent.pem" })), + ).rejects.toThrow("--ca-cert:"); + }); + + it("returns warning when all options are valid", async () => { + mockDnsLookupPrivate("private.test"); + const warning = await validateOptions(opts({ osvUrl: "https://private.test", allowPrivateOsvUrl: true })); + expect(warning).toContain("--allow-private-osv-url is set"); + }); + }); }); From 9e5f8288b5ee606f527b63779ccbd908c3efb5d4 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 25 Jun 2026 16:06:38 +0800 Subject: [PATCH 7/9] docs(security-assurance-case): fix A10 SSRF description to match implementation --- website/docs/security-assurance-case.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/security-assurance-case.md b/website/docs/security-assurance-case.md index ac4d718..4465ec3 100644 --- a/website/docs/security-assurance-case.md +++ b/website/docs/security-assurance-case.md @@ -121,7 +121,7 @@ Mapped against the [OWASP Top 10 (2021)](https://owasp.org/Top10/) — the categ | A07: Identification and Authentication Failures | Not applicable | The CLI has no authentication surface. | | A08: Software and Data Integrity Failures | Countered | Release tarballs are signed via Sigstore Artifact Attestations. Git tags are GPG-signed by the project lead. Withdrawn OSV advisories are filtered at ingest. Sync replaces the advisory database atomically rather than partially updating it. | | A09: Security Logging and Monitoring Failures | Partially countered | CodeQL alerts on every push, the self-scan workflow flags new advisories in the project's own dependencies on every push, and the GitHub release workflow emits build provenance. The project does not centrally log scan invocations because none happen on project-controlled infrastructure — scans run on each user's local machine. | -| A10: Server-Side Request Forgery | Countered | The CLI only makes outbound HTTP requests to fixed, hardcoded endpoints: `api.osv.dev`, `storage.googleapis.com/osv-vulnerabilities/...`, and `registry.npmjs.org`. The optional `--osv-url` flag enforces HTTPS and blocks private/reserved IP ranges (RFC 1918, loopback, link-local) by default to prevent SSRF attacks. Using `--allow-private-osv-url` bypasses this protection and should only be used with trusted internal mirrors. | +| A10: Server-Side Request Forgery | Countered | The CLI makes outbound HTTP requests to its default advisory and registry endpoints and can optionally use a user-supplied OSV-compatible endpoint via `--osv-url`. That flag enforces HTTPS and validates DNS resolution against all private and reserved IP ranges defined by IANA (RFC 1918, loopback, link-local, multicast, CGNAT, etc.) by default to prevent SSRF attacks. Using `--allow-private-osv-url` bypasses this protection and should only be used with trusted internal mirrors. | ## Limitations and explicit non-goals From ca1be3b10b368a9f02e4006cc1c9fca6dbd8f722 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 25 Jun 2026 21:28:36 +0800 Subject: [PATCH 8/9] refactor(validate-url): replace IPv4 regex with CIDR numerical matching --- src/utils/validate-url.ts | 82 ++++++++++++------ tests/validate-url.test.ts | 168 +++++++++++++++++++------------------ 2 files changed, 144 insertions(+), 106 deletions(-) diff --git a/src/utils/validate-url.ts b/src/utils/validate-url.ts index 2c1d740..3be3ad5 100644 --- a/src/utils/validate-url.ts +++ b/src/utils/validate-url.ts @@ -3,27 +3,56 @@ import { URL } from "node:url"; const OFFICIAL_OSV_HOSTS = ["api.osv.dev"]; -// RFC 1918, loopback, link-local, multicast, and other special-purpose addresses -// See: https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml -const PRIVATE_IP_PATTERNS = [ - // IPv4 special-purpose addresses - /^127\./, // loopback (127.0.0.0/8) - /^10\./, // private Class A (10.0.0.0/8) - /^172\.(1[6-9]|2\d|3[01])\./, // private Class B (172.16.0.0/12) - /^192\.168\./, // private Class C (192.168.0.0/16) - /^169\.254\./, // link-local (169.254.0.0/16) - /^0\./, // "this network" (0.0.0.0/8) - /^100\.(6[4-9]|[7-9]\d|1(?:[01]\d|2[0-7]))\./, // CGNAT (100.64.0.0/10) - /^192\.0\.0\./, // IETF Protocol (192.0.0.0/24) - /^192\.0\.2\./, // documentation (TEST-NET-1) - /^198\.51\.100\./, // documentation (TEST-NET-2) - /^203\.0\.113\./, // documentation (TEST-NET-3) - /^198\.1[8-9]\./, // benchmark (198.18.0.0/15) - /^(22[4-9]|23\d)\./, // multicast (224.0.0.0/4) - /^(24\d|25[0-5])\./, // reserved (240.0.0.0/4) - /^255\.255\.255\.255$/, // broadcast - - // IPv6 special-purpose addresses +// IPv4 special-purpose CIDRs — numerical match replaces error-prone regex +// https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml +const PRIVATE_IPV4_CIDRS = [ + "0.0.0.0/8", // "this network" + "10.0.0.0/8", // private Class A + "100.64.0.0/10", // CGNAT + "127.0.0.0/8", // loopback + "169.254.0.0/16", // link-local + "172.16.0.0/12", // private Class B + "192.168.0.0/16", // private Class C + "192.0.0.0/24", // IETF Protocol + "192.0.2.0/24", // documentation (TEST-NET-1) + "198.18.0.0/15", // benchmark + "198.51.100.0/24", // documentation (TEST-NET-2) + "203.0.113.0/24", // documentation (TEST-NET-3) + "224.0.0.0/4", // multicast + "240.0.0.0/4", // reserved + "255.255.255.255/32", // broadcast +]; + +function ipv4ToU32(ip: string): number | undefined { + const parts = ip.split("."); + if (parts.length !== 4) return undefined; + let n = 0; + for (const part of parts) { + if (!/^\d+$/.test(part)) return undefined; + const value = Number(part); + if (value < 0 || value > 255) return undefined; + n = (n << 8) + value; + } + return n >>> 0; +} + +function ipv4InCidr(ip: string, cidr: string): boolean { + const [base, bitsText] = cidr.split("/"); + const bits = Number(bitsText); + const ipNum = ipv4ToU32(ip); + const baseNum = ipv4ToU32(base); + if (ipNum === undefined || baseNum === undefined) return false; + if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false; + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return (ipNum & mask) === (baseNum & mask); +} + +function isPrivateOrReservedIpv4(ip: string): boolean { + return PRIVATE_IPV4_CIDRS.some(cidr => ipv4InCidr(ip, cidr)); +} + +// IPv6 special-purpose addresses (regex — no CIDR representation for these) +const IPV6_PATTERNS = [ /^::$/, // unspecified (::/128) /^::1$/, // loopback (::1/128) /^::ffff:(?:255\.255\.255\.255|0\.0\.0\.0)$/, // IPv4-mapped broadcast/unspecified @@ -59,10 +88,15 @@ export function isOfficialOsv(url: string): boolean { export function isPrivateOrReservedIp(ip: string): boolean { // Check for IPv4-mapped IPv6 address (e.g., ::ffff:127.0.0.1) const ipv4 = isIPv4MappedIPv6(ip); - if (ipv4 !== null) { - return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(ipv4)); + const target = ipv4 ?? ip; + + // Fast-path: IPv4 CIDR match + if (ipv4ToU32(target) !== undefined) { + return isPrivateOrReservedIpv4(target); } - return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(ip)); + + // IPv6: regex match + return IPV6_PATTERNS.some(pattern => pattern.test(target)); } export async function validateOsvUrl( diff --git a/tests/validate-url.test.ts b/tests/validate-url.test.ts index a577bb6..a83f057 100644 --- a/tests/validate-url.test.ts +++ b/tests/validate-url.test.ts @@ -25,82 +25,92 @@ describe("isOfficialOsv", () => { }); describe("isPrivateOrReservedIp", () => { - it("detects loopback 127.x", () => { - expect(isPrivateOrReservedIp("127.0.0.1")).toBe(true); - expect(isPrivateOrReservedIp("127.255.255.255")).toBe(true); - }); - - it("detects private Class A 10.x", () => { - expect(isPrivateOrReservedIp("10.0.0.1")).toBe(true); - expect(isPrivateOrReservedIp("10.255.255.255")).toBe(true); - }); - - it("detects private Class B 172.16-31.x", () => { - expect(isPrivateOrReservedIp("172.16.0.1")).toBe(true); - expect(isPrivateOrReservedIp("172.31.255.255")).toBe(true); - }); - - it("does not detect public IPs in 172.32-168.x range", () => { - expect(isPrivateOrReservedIp("172.32.0.1")).toBe(false); - }); - - it("detects private Class C 192.168.x", () => { - expect(isPrivateOrReservedIp("192.168.0.1")).toBe(true); - expect(isPrivateOrReservedIp("192.168.255.255")).toBe(true); - }); - - it("detects link-local 169.254.x", () => { - expect(isPrivateOrReservedIp("169.254.0.1")).toBe(true); - expect(isPrivateOrReservedIp("169.254.255.255")).toBe(true); - }); - - it("detects 'this network' 0.x", () => { - expect(isPrivateOrReservedIp("0.0.0.0")).toBe(true); - }); - - it("detects CGNAT 100.64.x.x", () => { - expect(isPrivateOrReservedIp("100.64.0.1")).toBe(true); - expect(isPrivateOrReservedIp("100.127.255.255")).toBe(true); - expect(isPrivateOrReservedIp("100.63.0.1")).toBe(false); - expect(isPrivateOrReservedIp("100.128.0.1")).toBe(false); - }); - - it("detects CGNAT edge cases that were previously missed", () => { - expect(isPrivateOrReservedIp("100.108.0.1")).toBe(true); - expect(isPrivateOrReservedIp("100.109.0.1")).toBe(true); - expect(isPrivateOrReservedIp("100.118.0.1")).toBe(true); - expect(isPrivateOrReservedIp("100.119.0.1")).toBe(true); - }); - - it("detects documentation addresses", () => { - expect(isPrivateOrReservedIp("192.0.2.1")).toBe(true); - expect(isPrivateOrReservedIp("198.51.100.1")).toBe(true); - expect(isPrivateOrReservedIp("203.0.113.1")).toBe(true); - }); - - it("detects IETF protocol 192.0.0.x", () => { - expect(isPrivateOrReservedIp("192.0.0.1")).toBe(true); - }); - - it("detects benchmark 198.18.x.x", () => { - expect(isPrivateOrReservedIp("198.18.0.1")).toBe(true); - expect(isPrivateOrReservedIp("198.19.255.255")).toBe(true); - }); - - it("detects reserved 240.0.0.0/4 range", () => { - expect(isPrivateOrReservedIp("240.0.0.1")).toBe(true); - expect(isPrivateOrReservedIp("241.0.0.1")).toBe(true); - expect(isPrivateOrReservedIp("255.0.0.1")).toBe(true); - }); - - it("detects broadcast 255.255.255.255", () => { - expect(isPrivateOrReservedIp("255.255.255.255")).toBe(true); - }); - - it("detects multicast 224-239.x.x.x", () => { - expect(isPrivateOrReservedIp("224.0.0.1")).toBe(true); - expect(isPrivateOrReservedIp("239.255.255.255")).toBe(true); - expect(isPrivateOrReservedIp("223.255.255.255")).toBe(false); + it.each([ + // 0.0.0.0/8 — "this network" + ["0.0.0.0", true], + ["0.255.255.255", true], + ["1.0.0.0", false], + + // 10.0.0.0/8 — private Class A + ["10.0.0.0", true], + ["10.255.255.255", true], + ["9.255.255.255", false], + ["11.0.0.0", false], + + // 100.64.0.0/10 — CGNAT + ["100.64.0.0", true], + ["100.127.255.255", true], + ["100.63.255.255", false], + ["100.128.0.0", false], + ["100.108.0.1", true], + ["100.109.0.1", true], + ["100.118.0.1", true], + ["100.119.0.1", true], + + // 127.0.0.0/8 — loopback + ["127.0.0.0", true], + ["127.255.255.255", true], + ["128.0.0.0", false], + + // 169.254.0.0/16 — link-local + ["169.254.0.0", true], + ["169.254.255.255", true], + ["169.253.255.255", false], + ["169.255.0.0", false], + + // 172.16.0.0/12 — private Class B + ["172.16.0.0", true], + ["172.31.255.255", true], + ["172.15.255.255", false], + ["172.32.0.0", false], + + // 192.168.0.0/16 — private Class C + ["192.168.0.0", true], + ["192.168.255.255", true], + ["192.167.255.255", false], + ["192.169.0.0", false], + + // 192.0.0.0/24 — IETF Protocol + ["192.0.0.0", true], + ["192.0.0.255", true], + ["192.0.1.0", false], + + // 198.18.0.0/15 — benchmark + ["198.18.0.0", true], + ["198.19.255.255", true], + ["198.17.255.255", false], + ["198.20.0.0", false], + + // 192.0.2.0/24 — documentation (TEST-NET-1) + ["192.0.2.0", true], + ["192.0.2.255", true], + ["192.0.3.0", false], + + // 198.51.100.0/24 — documentation (TEST-NET-2) + ["198.51.100.0", true], + ["198.51.100.255", true], + ["198.51.101.0", false], + + // 203.0.113.0/24 — documentation (TEST-NET-3) + ["203.0.113.0", true], + ["203.0.113.255", true], + ["203.0.114.0", false], + + // 224.0.0.0/4 — multicast + ["224.0.0.0", true], + ["239.255.255.255", true], + ["223.255.255.255", false], + + // 240.0.0.0/4 — reserved + ["240.0.0.0", true], + ["255.255.255.255", true], + + // public IPv4 + ["8.8.8.8", false], + ["1.1.1.1", false], + ["93.184.216.34", false], + ])("%s → %s", (ip, expected) => { + expect(isPrivateOrReservedIp(ip)).toBe(expected); }); it("detects IPv6 loopback", () => { @@ -135,12 +145,6 @@ describe("isPrivateOrReservedIp", () => { expect(isPrivateOrReservedIp("::ffff:8.8.8.8")).toBe(false); }); - it("does not detect public IPv4", () => { - expect(isPrivateOrReservedIp("8.8.8.8")).toBe(false); - expect(isPrivateOrReservedIp("1.1.1.1")).toBe(false); - expect(isPrivateOrReservedIp("93.184.216.34")).toBe(false); - }); - it("does not detect public IPv6", () => { expect(isPrivateOrReservedIp("2606:4700::1")).toBe(false); }); From 35a5edbd2d6f515e609e6f677ea2ea130be921f9 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 25 Jun 2026 21:39:36 +0800 Subject: [PATCH 9/9] docs(validate-url): fix IPv6 comment to avoid implying IPv6 has no CIDR --- src/utils/validate-url.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/validate-url.ts b/src/utils/validate-url.ts index 3be3ad5..ba1487d 100644 --- a/src/utils/validate-url.ts +++ b/src/utils/validate-url.ts @@ -51,7 +51,7 @@ function isPrivateOrReservedIpv4(ip: string): boolean { return PRIVATE_IPV4_CIDRS.some(cidr => ipv4InCidr(ip, cidr)); } -// IPv6 special-purpose addresses (regex — no CIDR representation for these) +// IPv6 special-purpose ranges — kept as prefix regex checks to avoid extra dependencies. const IPV6_PATTERNS = [ /^::$/, // unspecified (::/128) /^::1$/, // loopback (::1/128)