-
-
Notifications
You must be signed in to change notification settings - Fork 10k
feat(providers): allow local/private provider URLs by default with metadata-safe guard (#5066) #5107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(providers): allow local/private provider URLs by default with metadata-safe guard (#5066) #5107
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import path from "node:path"; | |
| const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-providers-validate-route-")); | ||
| process.env.DATA_DIR = TEST_DATA_DIR; | ||
| const originalAllowPrivateProviderUrls = process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; | ||
| const originalAllowLocalProviderUrls = process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; | ||
|
|
||
| // Load modules at top level | ||
| const core = await import("../../src/lib/db/core.ts"); | ||
|
|
@@ -27,6 +28,11 @@ test.after(() => { | |
| } else { | ||
| process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS = originalAllowPrivateProviderUrls; | ||
| } | ||
| if (originalAllowLocalProviderUrls === undefined) { | ||
| delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; | ||
| } else { | ||
| process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS = originalAllowLocalProviderUrls; | ||
| } | ||
| }); | ||
|
|
||
| test("providers validate route returns 400 for invalid JSON", async () => { | ||
|
|
@@ -106,9 +112,12 @@ test("providers validate route forwards baseUrl to built-in specialty validators | |
| } | ||
| }); | ||
|
|
||
| test("providers validate route blocks private baseUrl values by default", async () => { | ||
| test("providers validate route blocks private baseUrl values when local provider URLs are disabled", async () => { | ||
| await resetStorage(); | ||
| delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; | ||
| // #5066: local provider URLs are allowed by default; this test exercises the strict | ||
| // public-only path by explicitly disabling the local-first allowance. | ||
| process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS = "false"; | ||
|
|
||
| let called = false; | ||
| const originalFetch = globalThis.fetch; | ||
|
|
@@ -148,6 +157,73 @@ test("providers validate route blocks private baseUrl values by default", async | |
| reason: "Blocked private or local provider URL", | ||
| baseUrl: "http://127.0.0.1:8080", | ||
| }); | ||
| } finally { | ||
| globalThis.fetch = originalFetch; | ||
| delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; | ||
| } | ||
| }); | ||
|
|
||
| test("providers validate route allows a local baseUrl by default (#5066 local-first)", async () => { | ||
| await resetStorage(); | ||
| delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; | ||
| delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; // default ON | ||
|
|
||
| const originalFetch = globalThis.fetch; | ||
| globalThis.fetch = async (url, init = {}) => { | ||
| assert.equal(String(url), "http://127.0.0.1:3264/api/v1/chat/completions"); | ||
| return new Response(JSON.stringify({ error: "bad request" }), { status: 400 }); | ||
| }; | ||
|
|
||
| try { | ||
| const request = new Request("http://localhost/api/providers/validate", { | ||
| method: "POST", | ||
| body: JSON.stringify({ | ||
| provider: "heroku", | ||
| apiKey: "local-key", | ||
| baseUrl: "http://127.0.0.1:3264/api", | ||
| }), | ||
| }); | ||
|
|
||
| const response = await validateRoute.POST(request); | ||
| const body = (await response.json()) as { valid?: boolean }; | ||
|
|
||
| // A reachable local endpoint must NOT be SSRF-blocked — it validates (the 400 from the | ||
| // local server is a normal validation outcome, not an outbound-guard 503). | ||
| assert.equal(response.status, 200); | ||
| assert.equal(body.valid, true); | ||
| } finally { | ||
| globalThis.fetch = originalFetch; | ||
| } | ||
| }); | ||
|
|
||
| test("providers validate route still blocks cloud-metadata even with local URLs allowed (#5066)", async () => { | ||
| await resetStorage(); | ||
| delete process.env.OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS; | ||
| delete process.env.OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS; // default ON | ||
|
|
||
| let called = false; | ||
| const originalFetch = globalThis.fetch; | ||
| globalThis.fetch = async () => { | ||
| called = true; | ||
| return Response.json({ ok: true }); | ||
| }; | ||
|
|
||
| try { | ||
| const request = new Request("http://localhost/api/providers/validate", { | ||
| method: "POST", | ||
| body: JSON.stringify({ | ||
| provider: "heroku", | ||
| apiKey: "heroku-key", | ||
| baseUrl: "http://169.254.169.254/latest/meta-data", | ||
| }), | ||
| }); | ||
|
|
||
| const response = await validateRoute.POST(request); | ||
|
|
||
| // The IMDS / cloud-metadata pivot is never a valid provider endpoint — blocked even | ||
| // when local/private provider URLs are allowed. | ||
| assert.equal(response.status, 503); | ||
| assert.equal(called, false); | ||
| } finally { | ||
|
Comment on lines
+211
to
227
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current test only verifies that the standard IPv4 cloud-metadata address const metadataUrls = [\n "http://169.254.169.254/latest/meta-data",\n "http://[::ffff:169.254.169.254]/latest/meta-data",\n "http://[fd00:ec2::254]/latest/meta-data"\n ];\n\n try {\n for (const baseUrl of metadataUrls) {\n const request = new Request("http://localhost/api/providers/validate", {\n method: "POST",\n body: JSON.stringify({\n provider: "heroku",\n apiKey: "heroku-key",\n baseUrl,\n }),\n });\n\n const response = await validateRoute.POST(request);\n\n // The IMDS / cloud-metadata pivot is never a valid provider endpoint — blocked even\n // when local/private provider URLs are allowed.\n assert.equal(response.status, 503, 'Should block metadata URL: ' + baseUrl);\n assert.equal(called, false);\n }\n } finally { |
||
| globalThis.fetch = originalFetch; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An attacker can bypass the cloud-metadata block by using IPv4-mapped IPv6 addresses (e.g.,
[::ffff:169.254.169.254]). SinceisCloudMetadataHostonly performs simple string checks againstCLOUD_METADATA_HOSTNAMESand checks if the host starts with"169.254.", it will returnfalsefor IPv4-mapped IPv6 addresses. However, modern network stacks will resolve and route these addresses to the IPv4 link-local address, allowing the attacker to access the cloud metadata service (IMDS) and potentially retrieve sensitive IAM credentials.\n\nTo prevent this, we should normalize the hostname by stripping the::ffff:prefix if the remaining part is a valid IPv4 address before performing the metadata check.