Skip to content

Commit 5a97aab

Browse files
authored
Merge pull request #888 from AikidoSec/ipv4-mapped-ipv6
Improve support for IPv4 mapped IPv6 addresses
2 parents 87a4877 + 77ef1b7 commit 5a97aab

10 files changed

Lines changed: 227 additions & 31 deletions

File tree

end2end/tests/hono-xml-allowlists.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,19 @@ t.test("it blocks non-allowed IP addresses", (t) => {
185185
signal: AbortSignal.timeout(5000),
186186
});
187187
t.same(resp8.status, 403);
188+
189+
// IPv4-mapped IPv6 address should also be allowed (matches 4.3.2.1/32)
190+
const resp9 = await fetch("http://127.0.0.1:4002/add", {
191+
method: "POST",
192+
body: "<cat><name>Mapped</name></cat>",
193+
headers: {
194+
"Content-Type": "application/xml",
195+
"X-Forwarded-For": "::ffff:4.3.2.1",
196+
},
197+
signal: AbortSignal.timeout(5000),
198+
});
199+
t.same(resp9.status, 200);
200+
t.same(await resp9.text(), JSON.stringify({ success: true }));
188201
})
189202
.catch((error) => {
190203
t.fail(error);

end2end/tests/hono-xml-blocklists.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,22 @@ t.test("it blocks geo restricted IPs", (t) => {
143143
});
144144
t.same(resp3.status, 200);
145145
t.same(await resp3.text(), JSON.stringify({ success: true }));
146+
147+
// IPv4-mapped IPv6 address should also be blocked (matches 1.3.2.0/24)
148+
const resp4 = await fetch("http://127.0.0.1:4002/add", {
149+
method: "POST",
150+
body: "<cat><name>Mapped</name></cat>",
151+
headers: {
152+
"Content-Type": "application/xml",
153+
"X-Forwarded-For": "::ffff:1.3.2.4",
154+
},
155+
signal: AbortSignal.timeout(5000),
156+
});
157+
t.same(resp4.status, 403);
158+
t.same(
159+
await resp4.text(),
160+
"Your IP address is blocked due to geo restrictions. (Your IP: ::ffff:1.3.2.4)"
161+
);
146162
})
147163
.catch((error) => {
148164
t.fail(error);
@@ -305,6 +321,24 @@ t.test("it does not block bypass IP if in blocklist", (t) => {
305321
await resp3.text(),
306322
`Your IP address is not allowed to access this resource. (Your IP: 1.3.2.2)`
307323
);
324+
325+
// IPv4-mapped IPv6 address should also bypass (matches bypass list 1.3.2.1)
326+
const resp4 = await fetch("http://127.0.0.1:4004/", {
327+
headers: {
328+
"X-Forwarded-For": "::ffff:1.3.2.1",
329+
},
330+
signal: AbortSignal.timeout(5000),
331+
});
332+
t.same(resp4.status, 200);
333+
334+
// IPv4-mapped IPv6 address should also access endpoint allowlist
335+
const resp5 = await fetch("http://127.0.0.1:4004/admin", {
336+
headers: {
337+
"X-Forwarded-For": "::ffff:1.3.2.1",
338+
},
339+
signal: AbortSignal.timeout(5000),
340+
});
341+
t.same(resp5.status, 200);
308342
})
309343
.catch((error) => {
310344
t.fail(error);

library/agent/ServiceConfig.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { addIPv4MappedAddresses } from "../helpers/addIPv4MappedAddresses";
12
import { IPMatcher } from "../helpers/ip-matcher/IPMatcher";
23
import { LimitedContext, matchEndpoints } from "../helpers/matchEndpoints";
34
import { isPrivateIP } from "../vulnerabilities/ssrf/isPrivateIP";
@@ -50,12 +51,15 @@ export class ServiceConfig {
5051
this.graphqlFields = [];
5152

5253
for (const endpoint of endpointConfigs) {
53-
let allowedIPAddresses = undefined;
54+
let allowedIPAddresses: IPMatcher | undefined = undefined;
5455
if (
5556
Array.isArray(endpoint.allowedIPAddresses) &&
5657
endpoint.allowedIPAddresses.length > 0
5758
) {
58-
allowedIPAddresses = new IPMatcher(endpoint.allowedIPAddresses);
59+
// Small list, frequently accessed: add IPv4-mapped versions at creation time for fast lookups
60+
allowedIPAddresses = new IPMatcher(
61+
addIPv4MappedAddresses(endpoint.allowedIPAddresses)
62+
);
5963
}
6064

6165
const endpointConfig = { ...endpoint, allowedIPAddresses };
@@ -98,7 +102,10 @@ export class ServiceConfig {
98102
this.bypassedIPAddresses = undefined;
99103
return;
100104
}
101-
this.bypassedIPAddresses = new IPMatcher(ipAddresses);
105+
// Small list, frequently accessed: add IPv4-mapped versions at creation time for fast lookups
106+
this.bypassedIPAddresses = new IPMatcher(
107+
addIPv4MappedAddresses(ipAddresses)
108+
);
102109
}
103110

104111
isBypassedIP(ip: string) {
@@ -119,8 +126,8 @@ export class ServiceConfig {
119126
isIPAddressBlocked(
120127
ip: string
121128
): { blocked: true; reason: string } | { blocked: false } {
122-
const blocklist = this.blockedIPAddresses.find((blocklist) =>
123-
blocklist.blocklist.has(ip)
129+
const blocklist = this.blockedIPAddresses.find((list) =>
130+
list.blocklist.hasWithMappedCheck(ip)
124131
);
125132

126133
if (blocklist) {
@@ -136,6 +143,7 @@ export class ServiceConfig {
136143
for (const source of blockedIPAddresses) {
137144
this.blockedIPAddresses.push({
138145
key: source.key,
146+
// Large list: IPv4-mapped checked at lookup time to save memory
139147
blocklist: new IPMatcher(source.ips),
140148
description: source.description,
141149
});
@@ -152,6 +160,7 @@ export class ServiceConfig {
152160
for (const source of monitoredIPAddresses) {
153161
this.monitoredIPAddresses.push({
154162
key: source.key,
163+
// Large list: IPv4-mapped checked at lookup time to save memory
155164
list: new IPMatcher(source.ips),
156165
});
157166
}
@@ -213,13 +222,13 @@ export class ServiceConfig {
213222

214223
getMatchingBlockedIPListKeys(ip: string): string[] {
215224
return this.blockedIPAddresses
216-
.filter((list) => list.blocklist.has(ip))
225+
.filter((list) => list.blocklist.hasWithMappedCheck(ip))
217226
.map((list) => list.key);
218227
}
219228

220229
getMatchingMonitoredIPListKeys(ip: string): string[] {
221230
return this.monitoredIPAddresses
222-
.filter((list) => list.list.has(ip))
231+
.filter((list) => list.list.hasWithMappedCheck(ip))
223232
.map((list) => list.key);
224233
}
225234

@@ -232,6 +241,7 @@ export class ServiceConfig {
232241
continue;
233242
}
234243
this.allowedIPAddresses.push({
244+
// Large list: IPv4-mapped checked at lookup time to save memory
235245
allowlist: new IPMatcher(source.ips),
236246
description: source.description,
237247
});
@@ -253,7 +263,7 @@ export class ServiceConfig {
253263
}
254264

255265
const allowlist = this.allowedIPAddresses.find((list) =>
256-
list.allowlist.has(ip)
266+
list.allowlist.hasWithMappedCheck(ip)
257267
);
258268

259269
return { allowed: !!allowlist };
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as t from "tap";
2+
import { addIPv4MappedAddresses } from "./addIPv4MappedAddresses";
3+
4+
t.test("it adds IPv4-mapped IPv6 addresses", async (t) => {
5+
t.same(
6+
addIPv4MappedAddresses([
7+
"1.2.3.4",
8+
"23.45.67.89/24",
9+
"2606:2800:220:1:248:1893:25c8:1946",
10+
"2001:0db9:abcd:1234::/64",
11+
]),
12+
[
13+
"1.2.3.4",
14+
"23.45.67.89/24",
15+
"2606:2800:220:1:248:1893:25c8:1946",
16+
"2001:0db9:abcd:1234::/64",
17+
"::ffff:1.2.3.4/128",
18+
"::ffff:23.45.67.89/120",
19+
]
20+
);
21+
});
22+
23+
t.test("it handles empty array", async (t) => {
24+
t.same(addIPv4MappedAddresses([]), []);
25+
});
26+
27+
t.test("it handles only IPv6 addresses", async (t) => {
28+
t.same(addIPv4MappedAddresses(["2001:db8::1", "::1"]), [
29+
"2001:db8::1",
30+
"::1",
31+
]);
32+
});
33+
34+
t.test("it handles only IPv4 addresses", async (t) => {
35+
t.same(addIPv4MappedAddresses(["1.2.3.4", "5.6.7.8"]), [
36+
"1.2.3.4",
37+
"5.6.7.8",
38+
"::ffff:1.2.3.4/128",
39+
"::ffff:5.6.7.8/128",
40+
]);
41+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import mapIPv4ToIPv6 from "./mapIPv4ToIPv6";
2+
3+
/**
4+
* Adds IPv4-mapped IPv6 versions for all IPv4 addresses in the array.
5+
* e.g. ["1.2.3.4", "2001:db8::/32"] -> ["1.2.3.4", "2001:db8::/32", "::ffff:1.2.3.4/128"]
6+
*/
7+
export function addIPv4MappedAddresses(ips: string[]): string[] {
8+
const ipv4Addresses = ips.filter((ip) => !ip.includes(":"));
9+
10+
return ips.concat(ipv4Addresses.map(mapIPv4ToIPv6));
11+
}

library/helpers/ip-matcher/IPMatcher.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,53 @@ t.test("allow all ips", async (t) => {
204204
t.same(matcher.has("10.0.0.255"), true);
205205
t.same(matcher.has("192.168.1.1"), true);
206206
});
207+
208+
t.test("adjacent /4 ranges at end of address space", async (t) => {
209+
// Regression test for Network.contains() bug
210+
// 224.0.0.0/4 and 240.0.0.0/4 are adjacent ranges that should merge to 224.0.0.0/3
211+
// The bug was that contains() incorrectly returned true when the other network's
212+
// "next" address overflowed (240.0.0.0/4 extends to 255.255.255.255)
213+
const matcher = new IPMatcher(["224.0.0.0/4", "240.0.0.0/4"]);
214+
215+
t.same(matcher.has("224.0.0.1"), true);
216+
t.same(matcher.has("240.0.0.1"), true);
217+
t.same(matcher.has("255.255.255.255"), true);
218+
t.same(matcher.has("223.255.255.255"), false);
219+
});
220+
221+
t.test("hasWithMappedCheck matches direct IPv4", async (t) => {
222+
const matcher = new IPMatcher(["192.0.2.1"]);
223+
t.same(matcher.hasWithMappedCheck("192.0.2.1"), true);
224+
t.same(matcher.hasWithMappedCheck("192.0.2.2"), false);
225+
});
226+
227+
t.test(
228+
"hasWithMappedCheck matches IPv4-mapped IPv6 against IPv4 in list",
229+
async (t) => {
230+
const matcher = new IPMatcher(["192.0.2.1"]);
231+
t.same(matcher.hasWithMappedCheck("::ffff:192.0.2.1"), true);
232+
t.same(matcher.hasWithMappedCheck("::ffff:c000:201"), true);
233+
t.same(matcher.hasWithMappedCheck("::ffff:192.0.2.2"), false);
234+
}
235+
);
236+
237+
t.test(
238+
"hasWithMappedCheck matches IPv4-mapped IPv6 against IPv4 CIDR range",
239+
async (t) => {
240+
const matcher = new IPMatcher(["192.0.2.0/24"]);
241+
t.same(matcher.hasWithMappedCheck("::ffff:192.0.2.1"), true);
242+
t.same(matcher.hasWithMappedCheck("::ffff:192.0.2.255"), true);
243+
t.same(matcher.hasWithMappedCheck("::ffff:192.0.3.1"), false);
244+
}
245+
);
246+
247+
t.test("hasWithMappedCheck matches direct IPv6", async (t) => {
248+
const matcher = new IPMatcher(["2001:db8::1"]);
249+
t.same(matcher.hasWithMappedCheck("2001:db8::1"), true);
250+
t.same(matcher.hasWithMappedCheck("2001:db8::2"), false);
251+
});
252+
253+
t.test("hasWithMappedCheck matches explicit IPv4-mapped in list", async (t) => {
254+
const matcher = new IPMatcher(["::ffff:192.0.2.1"]);
255+
t.same(matcher.hasWithMappedCheck("::ffff:192.0.2.1"), true);
256+
});

library/helpers/ip-matcher/IPMatcher.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,43 @@ export class IPMatcher {
5353
this.sorted.splice(idx, 0, net);
5454
return this;
5555
}
56+
57+
// Checks if the given IP address is in the list of networks,
58+
// also checking the IPv4 address if it's an IPv4-mapped IPv6 address.
59+
public hasWithMappedCheck(ip: string): boolean {
60+
if (this.has(ip)) {
61+
return true;
62+
}
63+
64+
const ipv4 = this.extractIPv4FromMapped(ip);
65+
if (ipv4) {
66+
return this.has(ipv4);
67+
}
68+
69+
return false;
70+
}
71+
72+
private extractIPv4FromMapped(ip: string): string | null {
73+
const net = new Network(ip);
74+
if (!net.isValid()) {
75+
return null;
76+
}
77+
78+
const bytes = net.addr.bytes();
79+
if (bytes.length !== 16) {
80+
return null;
81+
}
82+
83+
// Check IPv4-mapped: first 10 bytes = 0, bytes 10-11 = 0xffff
84+
for (let i = 0; i < 10; i++) {
85+
if (bytes[i] !== 0) {
86+
return null;
87+
}
88+
}
89+
if (bytes[10] !== 255 || bytes[11] !== 255) {
90+
return null;
91+
}
92+
93+
return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`;
94+
}
5695
}

library/helpers/ip-matcher/Network.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ export class Network {
106106
// handle edge case where our next network address overflows
107107
if (!next.isValid()) return true;
108108

109+
// handle edge case where other network's next address overflows
110+
// (it extends to end of address space, but we don't, so we can't contain it)
111+
if (!otherNext.isValid()) return false;
112+
109113
// our address should be more than or equal to the other address
110114
if (next.addr.compare(otherNext.addr) === BEFORE) return false;
111115

library/vulnerabilities/ssrf/imds.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1+
import { addIPv4MappedAddresses } from "../../helpers/addIPv4MappedAddresses";
12
import { IPMatcher } from "../../helpers/ip-matcher/IPMatcher";
2-
import mapIPv4ToIPv6 from "../../helpers/mapIPv4ToIPv6";
33

4-
const IMDSAddresses = new IPMatcher();
5-
6-
// This IP address is used by AWS EC2 instances to access the instance metadata service (IMDS)
4+
// These IP addresses are used to access the instance metadata service (IMDS)
75
// We should block any requests to these IP addresses
86
// This prevents STORED SSRF attacks that try to access the instance metadata service
9-
IMDSAddresses.add("169.254.169.254");
10-
IMDSAddresses.add(mapIPv4ToIPv6("169.254.169.254"));
11-
IMDSAddresses.add("fd00:ec2::254");
12-
IMDSAddresses.add("100.100.100.200"); // Alibaba Cloud
13-
IMDSAddresses.add(mapIPv4ToIPv6("100.100.100.200"));
7+
// Small list, frequently accessed: add IPv4-mapped versions at creation time for fast lookups
8+
const IMDSAddresses = new IPMatcher(
9+
addIPv4MappedAddresses([
10+
"169.254.169.254",
11+
"fd00:ec2::254",
12+
"100.100.100.200",
13+
])
14+
);
1415

1516
export function isIMDSIPAddress(ip: string): boolean {
1617
return IMDSAddresses.has(ip);

library/vulnerabilities/ssrf/isPrivateIP.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { addIPv4MappedAddresses } from "../../helpers/addIPv4MappedAddresses";
12
import { IPMatcher } from "../../helpers/ip-matcher/IPMatcher";
2-
import mapIPv4ToIPv6 from "../../helpers/mapIPv4ToIPv6";
33

44
const PRIVATE_IP_RANGES = [
55
"0.0.0.0/8", // "This" network (RFC 1122)
@@ -31,20 +31,13 @@ const PRIVATE_IPV6_RANGES = [
3131
"100::/64", // Discard prefix (RFC 6666)
3232
"2001:db8::/32", // Documentation prefix (RFC 3849)
3333
"3fff::/20", // Documentation prefix (RFC 9637)
34-
].concat(
35-
// Add the IPv4-mapped IPv6 addresses
36-
PRIVATE_IP_RANGES.map(mapIPv4ToIPv6)
37-
);
38-
39-
const privateIp = new IPMatcher();
40-
41-
PRIVATE_IP_RANGES.forEach((range) => {
42-
privateIp.add(range);
43-
});
34+
];
4435

45-
PRIVATE_IPV6_RANGES.forEach((range) => {
46-
privateIp.add(range);
47-
});
36+
// Small list, frequently accessed: add IPv4-mapped versions at creation time for fast lookups
37+
const privateIp = new IPMatcher([
38+
...addIPv4MappedAddresses(PRIVATE_IP_RANGES),
39+
...PRIVATE_IPV6_RANGES,
40+
]);
4841

4942
export function isPrivateIP(ip: string): boolean {
5043
return privateIp.has(ip);

0 commit comments

Comments
 (0)