ktg-plugin-marketplace/plugins/voyage/lib/exporters/path-validator.mjs
Kjell Tore Guttormsen 9e01ce30b5 feat(voyage): add lib/exporters/{path,endpoint,field-allowlist}-validators — CWE-22, CWE-918, CWE-212 mitigering
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>
2026-05-09 09:36:00 +02:00

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 };