Skip to content

Commit fab5a73

Browse files
committed
fix: reject mapped private IPv6 hosts
Signed-off-by: Anchen <anchenqlw@users.noreply.github.com>
1 parent da75e6d commit fab5a73

2 files changed

Lines changed: 42 additions & 10 deletions

File tree

‎scripts/validate.mjs‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { access, readFile } from 'node:fs/promises';
5+
import { isIP } from 'node:net';
56
import path from 'node:path';
67
import { fileURLToPath } from 'node:url';
78
import { parseArgs } from 'node:util';
@@ -19,19 +20,37 @@ const internalPattern = /(?:alibaba-inc\.com|alibabacloud\.com\.cn|localhost|127
1920
const secretPattern = /(?:-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|\bAKIA[0-9A-Z]{16}\b|\bgh[pousr]_[A-Za-z0-9]{20,}\b|\bsk-[A-Za-z0-9_-]{20,}\b)/;
2021
let mermaidPromise;
2122

23+
function privateIpv4(octets) {
24+
const [first, second] = octets;
25+
return first === 0 || first === 10 || first === 127 || (first === 100 && second >= 64 && second <= 127)
26+
|| (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168);
27+
}
28+
29+
function ipv6Words(value) {
30+
if (isIP(value) !== 6) return null;
31+
const [leftSource, rightSource = ''] = value.split('::');
32+
const parse = (source) => source ? source.split(':').filter(Boolean).map((part) => Number.parseInt(part, 16)) : [];
33+
const left = parse(leftSource);
34+
const right = parse(rightSource);
35+
const omitted = 8 - left.length - right.length;
36+
if ((value.includes('::') && omitted < 1) || (!value.includes('::') && omitted !== 0)) return null;
37+
return [...left, ...Array(omitted).fill(0), ...right];
38+
}
39+
2240
function isPrivateHostname(value) {
2341
const hostname = value.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
2442
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local') || hostname.endsWith('.internal')) return true;
25-
if (hostname.includes(':')) {
26-
if (hostname === '::' || hostname === '::1' || hostname.startsWith('fc') || hostname.startsWith('fd') || /^fe[89ab]/.test(hostname)) return true;
27-
if (hostname.startsWith('::ffff:')) return isPrivateHostname(hostname.slice('::ffff:'.length));
28-
return false;
43+
if (isIP(hostname) === 4) return privateIpv4(hostname.split('.').map(Number));
44+
const words = ipv6Words(hostname);
45+
if (!words) return false;
46+
if ((words[0] & 0xfe00) === 0xfc00 || (words[0] & 0xffc0) === 0xfe80) return true;
47+
const compatiblePrefix = words.slice(0, 6).every((word) => word === 0);
48+
const mappedPrefix = words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff;
49+
if (compatiblePrefix || mappedPrefix) {
50+
const octets = [words[6] >> 8, words[6] & 0xff, words[7] >> 8, words[7] & 0xff];
51+
return privateIpv4(octets);
2952
}
30-
const octets = hostname.split('.').map(Number);
31-
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return false;
32-
const [first, second] = octets;
33-
return first === 0 || first === 10 || first === 127 || (first === 100 && second >= 64 && second <= 127)
34-
|| (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168);
53+
return false;
3554
}
3655

3756
async function parseMermaid(diagram) {

‎tests/validator.test.mjs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,20 @@ test('rejects internal, credentialed, video, and markup-bearing metadata', async
198198
});
199199

200200
test('rejects IPv6 loopback, private, and link-local source URLs', async () => {
201-
for (const sourceUrl of ['https://[::1]/private', 'https://[fd00::1]/private', 'https://[fe80::1]/private']) {
201+
for (const sourceUrl of [
202+
'https://[::1]/private',
203+
'https://[fd00::1]/private',
204+
'https://[fe80::1]/private',
205+
'https://[::ffff:127.0.0.1]/private',
206+
'https://[::ffff:10.0.0.1]/private',
207+
'https://[::127.0.0.1]/private',
208+
'https://[::ffff:7f00:1]/private',
209+
'https://[::ffff:a00:1]/private',
210+
'https://[::ffff:ac10:1]/private',
211+
'https://[::ffff:c0a8:1]/private',
212+
'https://[::ffff:a9fe:a9fe]/private',
213+
'https://[::7f00:1]/private'
214+
]) {
202215
const result = await validateMutation((source) => source.replace('locale: zh-CN', `locale: zh-CN\nsource_url: ${sourceUrl}`));
203216
assert.ok(rules(result).has('LINK-005'), sourceUrl);
204217
}

0 commit comments

Comments
 (0)