Skip to content

Commit bd43c33

Browse files
aborrusoclaude
andauthored
fix(ssrf): refuse env proxies, block multicast/reserved/site-local ranges (#531)
* fix(ssrf): refuse env proxies, block multicast/reserved/site-local ranges Aligned with datagouv-mcp v1.0.0 hardening (PR #126). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtqW9nD9oBQT39xB4mj2bg * test(ssrf): assert proxy:false on axios path; double quotes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtqW9nD9oBQT39xB4mj2bg * test(ssrf): use dati.gov.it as test target Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtqW9nD9oBQT39xB4mj2bg --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent d2d5104 commit bd43c33

3 files changed

Lines changed: 32 additions & 2 deletions

File tree

LOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# LOG
22

3+
## 2026-09-04
4+
5+
### SSRF guard aligned with datagouv-mcp v1.0.0
6+
7+
Compared our `isBlockedIp` and request chain against the SSRF hardening shipped in [datagouv-mcp v1.0.0](https://github.com/datagouv/datagouv-mcp/releases/tag/v1.0.0) (PR #126, external report). Connect-time pinning, redirect re-checks and IPv4-mapped/6to4/NAT64 unwrapping were already in place; their specific vector (fetching producer-supplied URLs from catalog metadata) does not apply here. Two gaps closed:
8+
9+
- `proxy: false` on the axios path: with `HTTP_PROXY`/`HTTPS_PROXY` set, axios connected to the proxy and the SSRF-safe lookup validated only the proxy's IP, not the target's.
10+
- `isBlockedIp` now also rejects 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved), ff00::/8 (IPv6 multicast) and fec0::/10 (site-local). Running their 37 test cases against our function surfaced exactly these.
11+
312
## 2026-08-31
413

514
### Support #4682889 answered: the Advisory Database queue reaches back to June 2026

src/utils/http.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,9 @@ export function isBlockedIp(ip: string): boolean {
330330
w[0] === 0x2002 || // 2002::/16 6to4
331331
(w[0] === 0x2001 && w[1] === 0x0000) || // 2001::/32 Teredo
332332
(w[0] & 0xfe00) === 0xfc00 || // fc00::/7 unique local
333-
(w[0] & 0xffc0) === 0xfe80 // fe80::/10 link-local
333+
(w[0] & 0xffc0) === 0xfe80 || // fe80::/10 link-local
334+
(w[0] & 0xffc0) === 0xfec0 || // fec0::/10 site-local (deprecated)
335+
(w[0] & 0xff00) === 0xff00 // ff00::/8 multicast
334336
);
335337
}
336338

@@ -348,7 +350,7 @@ export function isBlockedIp(ip: string): boolean {
348350
(o1 === 172 && o2 >= 16 && o2 <= 31) || // 172.16.0.0/12 private
349351
(o1 === 192 && o2 === 168) || // 192.168.0.0/16 private
350352
(o1 === 198 && (o2 === 18 || o2 === 19)) || // 198.18.0.0/15 benchmarking
351-
o1 === 255 // broadcast
353+
o1 >= 224 // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, broadcast
352354
);
353355
}
354356

@@ -713,6 +715,9 @@ export async function makeCkanRequest<T>(
713715
timeout: 30000,
714716
responseType: "arraybuffer",
715717
maxRedirects: 5,
718+
// Never route through HTTP_PROXY/HTTPS_PROXY: a proxy would connect to the
719+
// target itself, bypassing the SSRF-safe lookup pinned in the agents below.
720+
proxy: false,
716721
maxContentLength: MAX_RESPONSE_BYTES,
717722
maxBodyLength: MAX_RESPONSE_BYTES,
718723
...(safeAgents

tests/unit/http.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ describe('isBlockedIp', () => {
154154
expect(isBlockedIp('198.17.0.1')).toBe(false);
155155
expect(isBlockedIp('198.20.0.1')).toBe(false);
156156
});
157+
158+
it("blocks multicast, reserved and site-local ranges", () => {
159+
for (const ip of ["224.0.0.1", "239.255.255.255", "240.0.0.1", "255.255.255.255", "ff00::1", "ff02::1", "fec0::1", "feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"]) {
160+
expect(isBlockedIp(ip), ip).toBe(true);
161+
}
162+
expect(isBlockedIp("223.255.255.254")).toBe(false);
163+
});
157164
});
158165

159166
describe('GHSA-x32r-mh7g-q2rf bypass chain', () => {
@@ -432,6 +439,15 @@ describe('makeCkanRequest', () => {
432439
);
433440
});
434441

442+
it("disables environment proxies so the SSRF-safe lookup pins the real target", async () => {
443+
vi.mocked(axios.get).mockResolvedValue({ data: successResponse });
444+
445+
await makeCkanRequest("https://www.dati.gov.it/opendata", "ckan_status_show");
446+
447+
const axiosCall = vi.mocked(axios.get).mock.calls[0];
448+
expect(axiosCall[1].proxy).toBe(false);
449+
});
450+
435451
it('throws error when success=false in response', async () => {
436452
vi.mocked(axios.get).mockResolvedValue({
437453
data: {

0 commit comments

Comments
 (0)