Step 11 av v4.1-execute (Wave 2, Session 3).
3 sikkerhets-validatorer for OTel-eksporten:
path-validator.mjs (CWE-22 Path Traversal):
- Reject `..` segmenter, `~`-shorthand
- realpathSync symlink-resolution (med macOS quirk: /etc, /var, /tmp er
symlinks til /private/etc, /private/var, /private/tmp — begge former
i FORBIDDEN_PREFIXES)
- Allowlist-først evaluering: hvis allowedRoots gitt, det er primary defense
(caller's threat model). Forbidden-prefix-denylist er FALLBACK når
allowedRoots ikke spesifisert.
endpoint-validator.mjs (CWE-918 SSRF):
- Reject loopback (127.0.0.1, ::1, localhost, 0.0.0.0) UNLESS VOYAGE_OTEL_ALLOW_PRIVATE=1
- Reject RFC-1918 (10/8, 172.16/12, 192.168/16) UNLESS opt-in
- Reject link-local (169.254.x.x cloud metadata, fe80:* IPv6) UNLESS opt-in
- Krev https:// for non-private endpoints
- node:url-parsing, ingen runtime DNS-resolusjon (defense-in-depth)
field-allowlist.mjs (CWE-212 Improper Cross-boundary Removal of Sensitive Data):
- INLINE static const Object.freeze på modul-scope (IKKE runtime read fra fixtures)
- Per-schema allowlist for alle 8 schema-id (trekbrief, trekresearch, trekplan,
trekexecute, event-emit, post-bash-stats, trekreview, trekcontinue)
- Source-comment per allowlist refererer tests/fixtures/jsonl-schemas.md
- post-bash-stats DROPPER eksplisitt command_excerpt + session_id (CWE-212)
- event-emit applies sub-allowlist på payload-objekt (recursive)
- Unknown schema-type returnerer conservative {_schema_id, ts}
Tester (19 nye, baseline 413 → 432):
- path-validator x6 (CWE-22 traversal, forbidden-system, ~, allowedRoots accept/reject, drift-pin)
- endpoint-validator x7 (CWE-918 link-local, RFC-1918, loopback, https-required, opt-in, public-accept, empty-input)
- field-allowlist x6 (CWE-212 post-bash-stats, trekplan-PII, event-emit-payload, unknown-schema, Object.freeze, null-safe)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
105 lines
3.9 KiB
JavaScript
105 lines
3.9 KiB
JavaScript
// lib/exporters/path-validator.mjs
|
|
// Validate textfile output paths for the OTel exporter.
|
|
//
|
|
// CWE-22 (Path Traversal) mitigation: restrict writes to allowlist-anchored
|
|
// directories only. Reject `..`, absolute system paths (`/etc`, `/proc`, `/sys`,
|
|
// `/var/`, `/usr/`), home-shorthand `~`, and resolve symlinks via
|
|
// `fs.realpathSync` before checking.
|
|
|
|
import { realpathSync, existsSync, statSync } from 'node:fs';
|
|
import { resolve, normalize, sep } from 'node:path';
|
|
import { ok, fail, issue } from '../util/result.mjs';
|
|
|
|
// macOS quirk: /etc, /tmp, /var are symlinks to /private/etc, /private/tmp,
|
|
// /private/var; realpathSync resolves the symlink and adds the /private prefix.
|
|
// Include both forms so the deny check works on macOS + Linux.
|
|
const FORBIDDEN_PREFIXES = [
|
|
'/etc/', '/private/etc/',
|
|
'/proc/',
|
|
'/sys/',
|
|
'/var/', '/private/var/',
|
|
'/usr/',
|
|
'/bin/',
|
|
'/sbin/',
|
|
'/boot/',
|
|
'/dev/',
|
|
];
|
|
|
|
/**
|
|
* Validate that a path is safe for the OTel textfile exporter to write.
|
|
*
|
|
* @param {string} path Caller-supplied path.
|
|
* @param {{
|
|
* allowedRoots?: string[] // additional allow-list roots (e.g. CLAUDE_PLUGIN_DATA, VOYAGE_TEXTFILE_DIR)
|
|
* }} [opts]
|
|
* @returns {import('../util/result.mjs').Result}
|
|
*/
|
|
export function validateTextfilePath(path, opts = {}) {
|
|
if (typeof path !== 'string' || path.length === 0) {
|
|
return fail(issue('PATH_EMPTY', 'Path must be a non-empty string'));
|
|
}
|
|
|
|
// Reject home-shorthand — caller must expand explicitly
|
|
if (path.startsWith('~')) {
|
|
return fail(issue('PATH_HOME_SHORTHAND', `Path uses ~ shorthand (caller must expand): ${path}`));
|
|
}
|
|
|
|
// Normalize to absolute (relative becomes resolved against cwd)
|
|
const normalized = normalize(path);
|
|
|
|
// Reject any path component containing `..` (traversal attempt)
|
|
// Even after normalize, if `..` survives, the path leaves intended root.
|
|
const segments = normalized.split(sep);
|
|
if (segments.some(s => s === '..')) {
|
|
return fail(issue('PATH_TRAVERSAL', `Path contains traversal segment "..": ${path}`));
|
|
}
|
|
|
|
const absolute = resolve(normalized);
|
|
|
|
// Resolve symlinks if file exists; if it doesn't exist yet, resolve parent
|
|
let resolved;
|
|
try {
|
|
if (existsSync(absolute)) {
|
|
resolved = realpathSync(absolute);
|
|
} else {
|
|
// Resolve parent dir (which must exist for any meaningful write target)
|
|
const parent = absolute.split(sep).slice(0, -1).join(sep) || '/';
|
|
if (!existsSync(parent)) {
|
|
return fail(issue('PATH_PARENT_MISSING', `Parent directory does not exist: ${parent}`));
|
|
}
|
|
resolved = realpathSync(parent) + sep + absolute.split(sep).pop();
|
|
}
|
|
} catch (e) {
|
|
return fail(issue('PATH_RESOLVE_ERROR', `realpath failed: ${e.message}`));
|
|
}
|
|
|
|
// If allowedRoots is provided, that's the primary defense — caller has
|
|
// explicitly opted into a root. Reject anything outside; accept anything
|
|
// inside (callers vetting their roots is the threat model).
|
|
if (Array.isArray(opts.allowedRoots) && opts.allowedRoots.length > 0) {
|
|
const inside = opts.allowedRoots.some(root => {
|
|
if (typeof root !== 'string' || root.length === 0) return false;
|
|
let resolvedRoot;
|
|
try { resolvedRoot = realpathSync(root); }
|
|
catch { resolvedRoot = resolve(root); }
|
|
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + sep);
|
|
});
|
|
if (!inside) {
|
|
return fail(issue('PATH_OUT_OF_ALLOWLIST',
|
|
`Path ${resolved} is not under any allowed root: ${opts.allowedRoots.join(', ')}`));
|
|
}
|
|
return ok({ path: resolved });
|
|
}
|
|
|
|
// No allowedRoots: fall back to forbidden-system-prefix denylist.
|
|
for (const prefix of FORBIDDEN_PREFIXES) {
|
|
if (resolved.startsWith(prefix)) {
|
|
return fail(issue('PATH_FORBIDDEN_SYSTEM',
|
|
`Path resolves into forbidden system directory ${prefix}: ${resolved}`));
|
|
}
|
|
}
|
|
|
|
return ok({ path: resolved });
|
|
}
|
|
|
|
export { FORBIDDEN_PREFIXES };
|