@icjia/contrastcap 0.1.3 → 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 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
- - Scheme allowlist: `http:` and `https:` only. `file:`, `javascript:`, `data:`, `ftp:` etc. are rejected with a generic `Blocked URL scheme` error.
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
- Private / localhost IPs are **allowed** by designthe primary use case is auditing dev servers.
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",
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 -e \"try { require('child_process').execSync('playwright install chromium', { stdio: 'inherit' }) } catch (e) { console.error('[contrastcap] playwright install chromium failed:', e.message); process.exit(0) }\""
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/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 denylist — mirrors lightcap.
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
- BLOCKED_IP_PREFIXES: [
46
- '169.254.', // IPv4 link-local (AWS IMDS)
47
- 'fd00:', // IPv6 unique-local
48
- 'fe80:', // IPv6 link-local
49
- '::', // IPv6 unspecified/loopback-equivalent
50
- ],
51
- LOCALHOST_HOSTS: [
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 ──────────────────────────────────────────────────────
@@ -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
+ }
@@ -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
- async function isBlockedIp(hostname) {
5
- if (CONFIG.LOCALHOST_HOSTS.includes(hostname)) return false;
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
- const normalized = address.startsWith('::ffff:') ? address.slice(7) : address;
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 isBlockedIp(parsed.hostname)) {
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 = { isBlockedIp };
102
+ export const _test = { isBlockedHost, classifyIp };