@icjia/contrastcap 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -7
- package/package.json +3 -2
- package/scripts/postinstall.mjs +53 -0
- package/src/browser.js +6 -0
- package/src/cli.js +12 -8
- package/src/config.js +9 -10
- package/src/server.js +13 -8
- package/src/tools/checkElementContrast.js +2 -4
- package/src/utils/selectorValidate.js +19 -0
- package/src/utils/urlValidate.js +73 -6
package/README.md
CHANGED
|
@@ -199,6 +199,9 @@ Server + axe-core + Playwright versions, plus a non-blocking npm update check.
|
|
|
199
199
|
| `CONTRASTCAP_MAX_CONCURRENT` | `2` | Max concurrent audits per process |
|
|
200
200
|
| `CONTRASTCAP_VIEWPORT_WIDTH` | `1280` | Chromium viewport width |
|
|
201
201
|
| `CONTRASTCAP_VIEWPORT_HEIGHT` | `800` | Chromium viewport height |
|
|
202
|
+
| `CONTRASTCAP_BLOCK_PRIVATE` | unset | Set to `1` to block RFC1918 / loopback / CGNAT addresses (production hardening). See **Security**. |
|
|
203
|
+
| `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` | unset | Set to `1` to skip the Chromium download in `postinstall` (offline / air-gapped installs). |
|
|
204
|
+
| `PLAYWRIGHT_DOWNLOAD_HOST` | unset | Mirror host for Playwright's Chromium download. |
|
|
202
205
|
|
|
203
206
|
---
|
|
204
207
|
|
|
@@ -234,14 +237,25 @@ First-time publish is auto-detected (no existing version on npm) — the current
|
|
|
234
237
|
|
|
235
238
|
## Security
|
|
236
239
|
|
|
237
|
-
|
|
238
|
-
- Cloud-metadata hostnames blocked: `169.254.169.254`, `metadata.google.internal`, `metadata.azure.com`, `0.0.0.0`.
|
|
239
|
-
- IP-prefix denylist (DNS-resolved): IPv4 link-local (`169.254.`), IPv6 unique-local (`fd00:`), link-local (`fe80:`), unspecified (`::`). DNS-resolution failures fail closed.
|
|
240
|
-
- Post-navigation re-check: after `page.goto` settles, `page.url()` is re-validated before any pixel sampling.
|
|
241
|
-
- Generic error messages — no filesystem paths or stack traces are returned to MCP clients.
|
|
242
|
-
- No file writes. Screenshots are in-memory buffers consumed by `sharp` and discarded.
|
|
240
|
+
### Threat model
|
|
243
241
|
|
|
244
|
-
|
|
242
|
+
`contrastcap` is an MCP server invoked by an LLM that may be acting on prompt-injected, attacker-controlled content. The dangerous tools are `check_page_contrast` and `check_element_contrast` — both accept a URL and load it in headless Chromium. A malicious URL could attempt to pivot to internal network resources (SSRF), exfiltrate page content via element text, or load adversarial schemes (`file:`, `javascript:`, `data:`).
|
|
243
|
+
|
|
244
|
+
### Controls
|
|
245
|
+
|
|
246
|
+
- **Scheme allowlist**: `http:` and `https:` only. `file:`, `javascript:`, `data:`, `ftp:`, etc. are rejected with a generic `Blocked URL scheme` error.
|
|
247
|
+
- **Cloud-metadata blocklist** (always on): `169.254.169.254`, `metadata.google.internal`, `metadata.azure.com`, `0.0.0.0`.
|
|
248
|
+
- **CIDR-classified IP blocking** (always on): IPv4 link-local (`169.254.0.0/16`), IPv6 link-local (`fe80::/10`), IPv6 unspecified (`::`), IPv4 multicast/reserved (`224.0.0.0/4`+), IPv6 multicast (`ff00::/8`). IPv4-mapped IPv6 addresses are unwrapped first so `::ffff:169.254.169.254` is recognized as link-local. DNS-resolution failures fail closed.
|
|
249
|
+
- **Optional private-IP blocking**: set `CONTRASTCAP_BLOCK_PRIVATE=1` to also block RFC1918 (`10/8`, `172.16/12`, `192.168/16`), CGNAT (`100.64/10`), loopback (`127/8`, `::1`), and IPv6 ULA (`fc00::/7`). Off by default — the primary use case is auditing dev servers — but **strongly recommended** when running the server in a trusted internal network where the LLM should not be able to pivot to internal services via prompt injection.
|
|
250
|
+
- **Post-navigation re-check**: after `page.goto` settles, `page.url()` is re-validated against the same SSRF policy. This catches `http://attacker.com/redirect` → `http://10.0.0.5/admin`.
|
|
251
|
+
- **Selector hardening**: `check_element_contrast` rejects Playwright engine prefixes (`xpath=`, `text=`, `role=`, `internal:*`, `_react=`, `_vue=`, etc.) and chain operators (`>>`). Only plain CSS selectors are accepted, so a malicious selector cannot pivot to XPath / text-content matching to read arbitrary DOM text.
|
|
252
|
+
- **Generic error messages** — no filesystem paths or stack traces are returned to MCP clients.
|
|
253
|
+
- **No file writes.** Screenshots are in-memory buffers consumed by `sharp` and discarded.
|
|
254
|
+
- **Hardened postinstall**: Playwright's CLI is resolved through Node's module resolver (`require.resolve`) rather than `$PATH`, so a shadowed `playwright` binary cannot hijack the install. Chromium download can be skipped (`PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`) or mirrored (`PLAYWRIGHT_DOWNLOAD_HOST`). If Chromium is missing at runtime, the launcher emits an actionable error rather than a Playwright-internal stack trace.
|
|
255
|
+
|
|
256
|
+
### Audit history
|
|
257
|
+
|
|
258
|
+
A red/blue team audit covering the MCP tool surface, Playwright/browser launch, dependency posture, and publish pipeline was performed in 0.1.4 (see CHANGELOG). `pnpm audit` is clean (0 vulnerabilities across all dependencies).
|
|
245
259
|
|
|
246
260
|
---
|
|
247
261
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@icjia/contrastcap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "MCP server for automated WCAG contrast auditing via pixel-level analysis — resolves axe-core 'needs review' items by sampling actual rendered pixels in headless Chromium",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node src/server.js",
|
|
12
12
|
"test": "node --test test/*.test.js",
|
|
13
|
-
"postinstall": "node
|
|
13
|
+
"postinstall": "node scripts/postinstall.mjs"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"src/",
|
|
17
|
+
"scripts/postinstall.mjs",
|
|
17
18
|
"README.md"
|
|
18
19
|
],
|
|
19
20
|
"engines": {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Resolve Playwright's CLI through Node's module resolver (not $PATH) and
|
|
2
|
+
// invoke it directly. Failure is reported but does not abort the parent
|
|
3
|
+
// install — Chromium download can fail for legitimate reasons (offline CI,
|
|
4
|
+
// air-gapped network, restricted egress). Users who hit that get a clear
|
|
5
|
+
// error pointing to the manual fix instead of a silent broken install at
|
|
6
|
+
// audit time.
|
|
7
|
+
//
|
|
8
|
+
// Skip download entirely:
|
|
9
|
+
// PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
|
10
|
+
// Use a private mirror:
|
|
11
|
+
// PLAYWRIGHT_DOWNLOAD_HOST=https://mirror.example.com
|
|
12
|
+
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
if (process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1') {
|
|
17
|
+
console.error('[contrastcap] PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 — skipping Chromium install.');
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
|
|
23
|
+
let cliPath;
|
|
24
|
+
try {
|
|
25
|
+
cliPath = require.resolve('playwright/cli.js');
|
|
26
|
+
} catch {
|
|
27
|
+
try {
|
|
28
|
+
cliPath = require.resolve('playwright-core/cli.js');
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error('[contrastcap] could not resolve Playwright CLI:', err.message);
|
|
31
|
+
console.error('[contrastcap] run `npx playwright install chromium` manually before first audit.');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result = spawnSync(process.execPath, [cliPath, 'install', 'chromium'], {
|
|
37
|
+
stdio: 'inherit',
|
|
38
|
+
env: process.env,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (result.error) {
|
|
42
|
+
console.error('[contrastcap] playwright install chromium failed to spawn:', result.error.message);
|
|
43
|
+
console.error('[contrastcap] run `npx playwright install chromium` manually before first audit.');
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (result.status !== 0) {
|
|
48
|
+
console.error(`[contrastcap] playwright install chromium exited with status ${result.status}.`);
|
|
49
|
+
console.error('[contrastcap] run `npx playwright install chromium` manually before first audit.');
|
|
50
|
+
// Do not propagate non-zero — install can complete; runtime will surface a
|
|
51
|
+
// helpful error if Chromium is genuinely missing.
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
package/src/browser.js
CHANGED
|
@@ -26,6 +26,12 @@ export async function getBrowser() {
|
|
|
26
26
|
return b;
|
|
27
27
|
}).catch((err) => {
|
|
28
28
|
_launching = null;
|
|
29
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(err.message || '')) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"Chromium is not installed. Run `npx playwright install chromium` " +
|
|
32
|
+
"to download it (~200 MB), or set PLAYWRIGHT_DOWNLOAD_HOST to a mirror."
|
|
33
|
+
);
|
|
34
|
+
}
|
|
29
35
|
throw err;
|
|
30
36
|
});
|
|
31
37
|
|
package/src/cli.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { program } from 'commander';
|
|
4
4
|
import { readFileSync } from 'fs';
|
|
5
5
|
import { execFile } from 'child_process';
|
|
6
|
+
import { createRequire } from 'module';
|
|
6
7
|
import { setVerbosity, CONFIG } from './config.js';
|
|
7
8
|
import { closeBrowser } from './browser.js';
|
|
8
9
|
import { checkPageContrast } from './tools/checkPageContrast.js';
|
|
@@ -10,8 +11,17 @@ import { checkElementContrast } from './tools/checkElementContrast.js';
|
|
|
10
11
|
import { getContrastSummary } from './tools/getContrastSummary.js';
|
|
11
12
|
import { sanitizeError } from './utils/sanitizeError.js';
|
|
12
13
|
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
13
15
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)));
|
|
14
16
|
|
|
17
|
+
function readPkgVersion(specifier) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(require.resolve(`${specifier}/package.json`), 'utf8')).version;
|
|
20
|
+
} catch {
|
|
21
|
+
return 'unknown';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
program
|
|
16
26
|
.name('contrastcap')
|
|
17
27
|
.description('WCAG contrast auditor — pixel-level resolution of axe-core "needs review" items')
|
|
@@ -74,14 +84,8 @@ program
|
|
|
74
84
|
.command('status')
|
|
75
85
|
.description('Show version info')
|
|
76
86
|
.action(async () => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
axeVersion = JSON.parse(readFileSync(new URL('../node_modules/axe-core/package.json', import.meta.url))).version;
|
|
81
|
-
} catch { /* ignore */ }
|
|
82
|
-
try {
|
|
83
|
-
playwrightVersion = JSON.parse(readFileSync(new URL('../node_modules/playwright/package.json', import.meta.url))).version;
|
|
84
|
-
} catch { /* ignore */ }
|
|
87
|
+
const axeVersion = readPkgVersion('axe-core');
|
|
88
|
+
const playwrightVersion = readPkgVersion('playwright');
|
|
85
89
|
|
|
86
90
|
let latest = 'unknown';
|
|
87
91
|
try {
|
package/src/config.js
CHANGED
|
@@ -35,22 +35,21 @@ export const CONFIG = {
|
|
|
35
35
|
|
|
36
36
|
USER_AGENT: 'contrastcap-mcp/0.1 (WCAG contrast auditor)',
|
|
37
37
|
|
|
38
|
-
// SSRF
|
|
38
|
+
// SSRF policy.
|
|
39
|
+
// Always-blocked hostnames (cloud metadata + 0.0.0.0 sentinel).
|
|
39
40
|
BLOCKED_HOSTNAMES: [
|
|
40
41
|
'169.254.169.254',
|
|
41
42
|
'metadata.google.internal',
|
|
42
43
|
'metadata.azure.com',
|
|
43
44
|
'0.0.0.0',
|
|
44
45
|
],
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'localhost', '127.0.0.1', '::1', '[::1]',
|
|
53
|
-
],
|
|
46
|
+
// Network-class blocking is handled by CIDR classification in
|
|
47
|
+
// src/utils/urlValidate.js — link-local (incl. cloud metadata),
|
|
48
|
+
// unspecified, multicast, and reserved ranges are *always* blocked.
|
|
49
|
+
// Private/loopback/CGNAT are allowed by default so the dev-server
|
|
50
|
+
// workflow keeps working, and blocked when this flag is on.
|
|
51
|
+
// Read at call time so the env flag can be flipped per-process.
|
|
52
|
+
get BLOCK_PRIVATE_IPS() { return process.env.CONTRASTCAP_BLOCK_PRIVATE === '1'; },
|
|
54
53
|
};
|
|
55
54
|
|
|
56
55
|
// ─── Logging ──────────────────────────────────────────────────────
|
package/src/server.js
CHANGED
|
@@ -2,9 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import { readFileSync } from 'fs';
|
|
4
4
|
import { execFile } from 'child_process';
|
|
5
|
+
import { createRequire } from 'module';
|
|
5
6
|
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
|
|
6
7
|
import * as z from 'zod/v4';
|
|
7
8
|
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
function readPkgVersion(specifier) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(require.resolve(`${specifier}/package.json`), 'utf8')).version;
|
|
14
|
+
} catch {
|
|
15
|
+
return 'unknown';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
import { CONFIG, log, setVerbosity } from './config.js';
|
|
9
20
|
import { closeBrowser } from './browser.js';
|
|
10
21
|
import { sanitizeError } from './utils/sanitizeError.js';
|
|
@@ -20,14 +31,8 @@ if (process.argv.includes('--quiet')) setVerbosity('quiet');
|
|
|
20
31
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)));
|
|
21
32
|
const serverVersion = pkg.version;
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
axeVersion = JSON.parse(readFileSync(new URL('../node_modules/axe-core/package.json', import.meta.url))).version;
|
|
27
|
-
} catch { /* ignore */ }
|
|
28
|
-
try {
|
|
29
|
-
playwrightVersion = JSON.parse(readFileSync(new URL('../node_modules/playwright/package.json', import.meta.url))).version;
|
|
30
|
-
} catch { /* ignore */ }
|
|
34
|
+
const axeVersion = readPkgVersion('axe-core');
|
|
35
|
+
const playwrightVersion = readPkgVersion('playwright');
|
|
31
36
|
|
|
32
37
|
let _latestVersion = null;
|
|
33
38
|
const _latestPromise = new Promise((resolve) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CONFIG } from '../config.js';
|
|
2
2
|
import { validateUrl } from '../utils/urlValidate.js';
|
|
3
|
+
import { validateSelector } from '../utils/selectorValidate.js';
|
|
3
4
|
import { withPage } from '../browser.js';
|
|
4
5
|
import { sampleBackgroundColor } from '../engine/pixelSampler.js';
|
|
5
6
|
import {
|
|
@@ -20,10 +21,7 @@ function rgbStringToHex(rgbStr) {
|
|
|
20
21
|
|
|
21
22
|
export async function checkElementContrast(params) {
|
|
22
23
|
const level = params.level || CONFIG.DEFAULT_LEVEL;
|
|
23
|
-
const selector = params.selector;
|
|
24
|
-
if (typeof selector !== 'string' || selector.length === 0 || selector.length > CONFIG.SELECTOR_MAX_LEN) {
|
|
25
|
-
throw new Error('Invalid selector');
|
|
26
|
-
}
|
|
24
|
+
const selector = validateSelector(params.selector);
|
|
27
25
|
|
|
28
26
|
const validated = await validateUrl(params.url);
|
|
29
27
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CONFIG } from '../config.js';
|
|
2
|
+
|
|
3
|
+
// Reject Playwright engine-prefixed selectors (xpath=, text=, id=, css=, role=,
|
|
4
|
+
// nth=, visible=, internal:*, _react=, _vue=) and chain operators (`>>` / `>>>`).
|
|
5
|
+
// We accept only plain CSS selectors so a malicious caller cannot pivot into
|
|
6
|
+
// XPath / text-content matching to exfiltrate page content via element text.
|
|
7
|
+
const ENGINE_PREFIX = /^\s*(xpath|text|id|css|role|nth|visible|internal:[\w-]+|_react|_vue|data-testid|alt|placeholder|title|label)\s*=/i;
|
|
8
|
+
const CHAIN_OPERATOR = />>/;
|
|
9
|
+
|
|
10
|
+
export function validateSelector(selector) {
|
|
11
|
+
if (typeof selector !== 'string' || selector.length === 0
|
|
12
|
+
|| selector.length > CONFIG.SELECTOR_MAX_LEN) {
|
|
13
|
+
throw new Error('Invalid selector');
|
|
14
|
+
}
|
|
15
|
+
if (ENGINE_PREFIX.test(selector) || CHAIN_OPERATOR.test(selector)) {
|
|
16
|
+
throw new Error('Invalid selector');
|
|
17
|
+
}
|
|
18
|
+
return selector;
|
|
19
|
+
}
|
package/src/utils/urlValidate.js
CHANGED
|
@@ -1,12 +1,79 @@
|
|
|
1
1
|
import { lookup } from 'dns/promises';
|
|
2
|
+
import { isIP } from 'net';
|
|
2
3
|
import { CONFIG } from '../config.js';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
// Classify an IP address into a network category. Returns one of:
|
|
6
|
+
// 'loopback' – 127.0.0.0/8, ::1
|
|
7
|
+
// 'private' – RFC1918 (10/8, 172.16/12, 192.168/16), IPv6 ULA (fc00::/7)
|
|
8
|
+
// 'cgnat' – 100.64.0.0/10
|
|
9
|
+
// 'link-local' – 169.254.0.0/16 (incl. cloud metadata), fe80::/10
|
|
10
|
+
// 'unspecified'– 0.0.0.0, ::
|
|
11
|
+
// 'reserved' – multicast / class E / other non-routable
|
|
12
|
+
// 'public' – everything else
|
|
13
|
+
// 'invalid' – not parseable
|
|
14
|
+
function classifyIp(address) {
|
|
15
|
+
if (typeof address !== 'string') return 'invalid';
|
|
16
|
+
// Unwrap IPv4-mapped IPv6 (e.g. ::ffff:10.0.0.1) so v4 rules apply.
|
|
17
|
+
const ip = /^::ffff:/i.test(address) ? address.slice(7) : address;
|
|
18
|
+
const v = isIP(ip);
|
|
19
|
+
|
|
20
|
+
if (v === 4) {
|
|
21
|
+
const parts = ip.split('.').map(Number);
|
|
22
|
+
if (parts.length !== 4 || parts.some(n => !Number.isInteger(n) || n < 0 || n > 255)) {
|
|
23
|
+
return 'invalid';
|
|
24
|
+
}
|
|
25
|
+
const [a, b] = parts;
|
|
26
|
+
if (a === 0) return 'unspecified';
|
|
27
|
+
if (a === 127) return 'loopback';
|
|
28
|
+
if (a === 169 && b === 254) return 'link-local';
|
|
29
|
+
if (a === 10) return 'private';
|
|
30
|
+
if (a === 172 && b >= 16 && b <= 31) return 'private';
|
|
31
|
+
if (a === 192 && b === 168) return 'private';
|
|
32
|
+
if (a === 100 && b >= 64 && b <= 127) return 'cgnat';
|
|
33
|
+
if (a >= 224) return 'reserved'; // multicast (224-239), class E (240+), broadcast (255)
|
|
34
|
+
return 'public';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (v === 6) {
|
|
38
|
+
const lower = ip.toLowerCase();
|
|
39
|
+
if (lower === '::') return 'unspecified';
|
|
40
|
+
if (lower === '::1') return 'loopback';
|
|
41
|
+
if (/^fe[89ab][0-9a-f]?:/.test(lower)) return 'link-local';
|
|
42
|
+
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return 'private'; // fc00::/7 ULA
|
|
43
|
+
if (lower.startsWith('ff')) return 'reserved'; // multicast
|
|
44
|
+
return 'public';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return 'invalid';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isAllowedClass(category) {
|
|
51
|
+
switch (category) {
|
|
52
|
+
case 'public':
|
|
53
|
+
return true;
|
|
54
|
+
case 'loopback':
|
|
55
|
+
case 'private':
|
|
56
|
+
case 'cgnat':
|
|
57
|
+
return !CONFIG.BLOCK_PRIVATE_IPS;
|
|
58
|
+
// 'link-local' (cloud metadata), 'unspecified', 'reserved', 'invalid' → always blocked
|
|
59
|
+
default:
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function isBlockedHost(hostname) {
|
|
65
|
+
if (!hostname) return true;
|
|
66
|
+
// If the hostname is already a literal IP, classify directly without DNS.
|
|
67
|
+
if (isIP(hostname)) return !isAllowedClass(classifyIp(hostname));
|
|
68
|
+
// Strip surrounding brackets from IPv6 literals (URL hostnames keep them).
|
|
69
|
+
const stripped = hostname.startsWith('[') && hostname.endsWith(']')
|
|
70
|
+
? hostname.slice(1, -1)
|
|
71
|
+
: hostname;
|
|
72
|
+
if (isIP(stripped)) return !isAllowedClass(classifyIp(stripped));
|
|
73
|
+
|
|
6
74
|
try {
|
|
7
75
|
const { address } = await lookup(hostname);
|
|
8
|
-
|
|
9
|
-
return CONFIG.BLOCKED_IP_PREFIXES.some(p => normalized.startsWith(p));
|
|
76
|
+
return !isAllowedClass(classifyIp(address));
|
|
10
77
|
} catch {
|
|
11
78
|
return true; // DNS failure → fail closed
|
|
12
79
|
}
|
|
@@ -26,10 +93,10 @@ export async function validateUrl(url) {
|
|
|
26
93
|
if (CONFIG.BLOCKED_HOSTNAMES.includes(parsed.hostname)) {
|
|
27
94
|
throw new Error('Blocked URL');
|
|
28
95
|
}
|
|
29
|
-
if (await
|
|
96
|
+
if (await isBlockedHost(parsed.hostname)) {
|
|
30
97
|
throw new Error('Blocked URL');
|
|
31
98
|
}
|
|
32
99
|
return parsed.href;
|
|
33
100
|
}
|
|
34
101
|
|
|
35
|
-
export const _test = {
|
|
102
|
+
export const _test = { isBlockedHost, classifyIp };
|