mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
2c2d008dc2
* fix(security): mint idempotency keys properly, and resolve the extractor by path Three findings out of a sweep of every open Sonar security issue. The other 49 turned out to be false positives and are documented in sonar-project.properties rather than changed — each entry scoped to a file, never a whole rule, so a future Math.random() on an auth path still fails. The idempotency key is the one that can lose data. Both producers fell back to Math.random when crypto.randomUUID was missing, and randomUUID needs a secure context — TREK's own quickstart is http://localhost:3000 and LAN installs run on http://192.168.x.x, so on the deployments the README documents the fallback is what actually runs. The axios one was `Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a single character and 0 yields the empty string, which the interceptor reads as "no key" and skips deduplication for entirely. Two writes sharing a key collapse into one — the second gets the first one's cached response and the edit is gone with no error anywhere. Both now come from utils/randomId, which has the getRandomValues rung that works in insecure contexts too. The extractor probe stored the bare binary name, so every extraction re-resolved it through whatever PATH held at that moment. The official image sets KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install without it ran an unqualified name from a PATH it does not control. It now walks the search path itself and keeps the absolute path of the file it verified; execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through app-config because process.env is walled off in src/. * fix: four tests and sorts that were not doing what they claimed Sonar reported 43 high-impact reliability issues. Reading all of them, four are real; the rest are cosmetic or would be made worse by the suggested change. budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid ? 1 : 0` and the handler stores the value verbatim — the test asserted a payload the server never sends. The line below it already used a number. admin.controller.test.ts had a `.resolves` assertion with no await. The promise was dropped, so that line could not fail the test. The two to-do category lists sorted with a bare .sort(), which is byte order: every accented name files behind the whole ASCII range, so in a German trip "Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data. One side effect worth knowing: katColor picks a category's dot colour by its index, so re-sorting can move which category shows which colour — it already moves whenever a category is added, and the colour carries no meaning. Deliberately not changed: the two `| 0` truncations (Math.trunc is not equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the two .map() callbacks whose second parameter is declared and used, and the sha1 in thumbnail.service (its own comment says a swap would orphan every existing thumbnail). * refactor: give every button a type, and prefer the Number statics Two mechanical sweeps, no behaviour change intended in either. A <button> with no type attribute is type="submit" per the HTML spec. Outside a form that does nothing today, but it turns into a surprise the moment the markup is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get an explicit type="button". Two things were left alone on purpose: buttons that take a {...spread}, because the spread may carry a type and the merge order decides which wins, and anything inside a <form> — there the answer is a judgement call, and checking all eleven form files found every real submit button already carrying type="submit". The Number sweep covers only the three globals that are exactly equivalent to their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false, and renaming it would change what the code does. It reaches server and plugin-sdk as well as the client; both copies of egress-policy.ts got the identical edit, so the mirror the SDK's parity test guards stays intact.
170 lines
7.8 KiB
TypeScript
170 lines
7.8 KiB
TypeScript
/**
|
|
* Pure egress-policy helpers for the plugin child's network guard (#plugins, L1
|
|
* hardening). Kept dependency-free so the isolated child can import them without
|
|
* pulling in any privileged server module, and so they are unit-testable on
|
|
* their own (the guard that uses them lives in plugin-host-entry, which is
|
|
* excluded from coverage as a subprocess entry).
|
|
*/
|
|
|
|
/**
|
|
* Block outbound connections to loopback / private / link-local / ULA / carrier-
|
|
* grade-NAT / cloud-metadata / multicast / reserved destinations. This is the
|
|
* SSRF backstop: even a declared host that (re)resolves to one of these is
|
|
* refused, so a plugin can't pivot to trek.db's host, the 169.254.169.254
|
|
* metadata IP, the local docker network, or other internal services.
|
|
*/
|
|
export function isBlockedIp(ip: string): boolean {
|
|
const v4 = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
if (v4) {
|
|
const a = Number(v4[1]);
|
|
const b = Number(v4[2]);
|
|
return (
|
|
a === 0 ||
|
|
a === 10 ||
|
|
a === 127 ||
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
(a === 192 && b === 168) ||
|
|
(a === 169 && b === 254) || // link-local, incl. 169.254.169.254 metadata
|
|
(a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
|
|
a >= 224 // multicast + reserved
|
|
);
|
|
}
|
|
// IPv6: expand to 8 hextets so EVERY spelling is checked (compressed `::`,
|
|
// hex IPv4-mapped `::ffff:a9fe:a9fe`, dotted `::ffff:169.254.169.254`, `0::1`).
|
|
const g = expandV6(ip.replace(/^\[/, '').replace(/\]$/, ''));
|
|
if (g) {
|
|
if (g.every((x) => x === 0)) return true; // :: (unspecified)
|
|
// IPv4-mapped (::ffff:x) and IPv4-compatible (::x) → check the embedded IPv4.
|
|
if (g.slice(0, 5).every((x) => x === 0) && (g[5] === 0xffff || g[5] === 0)) {
|
|
return isBlockedIp(`${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`);
|
|
}
|
|
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
|
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
|
|
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding an IPv4 → re-check
|
|
// the embedded target, so a NAT64 spelling of 169.254.169.254 can't tunnel past.
|
|
const embedded = embeddedTransitionIpv4(g);
|
|
if (embedded) return isBlockedIp(embedded);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* If the expanded hextets `g` are an IPv6 transition address that embeds an IPv4
|
|
* target, return that IPv4 in dotted form; otherwise null. Recognises the NAT64
|
|
* well-known prefix `64:ff9b::/96`, 6to4 `2002::/16`, and Teredo `2001:0000::/32`.
|
|
* Kept local so this module stays dependency-free for the isolated plugin child.
|
|
*/
|
|
function embeddedTransitionIpv4(g: number[]): string | null {
|
|
const v4 = (hi: number, lo: number) => `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
// NAT64 well-known 64:ff9b::/96 — first 96 bits fixed, last 32 = IPv4.
|
|
if (g[0] === 0x0064 && g[1] === 0xff9b && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
|
|
return v4(g[6], g[7]);
|
|
}
|
|
// 6to4 2002::/16 — hextets 1,2 = IPv4.
|
|
if (g[0] === 0x2002) return v4(g[1], g[2]);
|
|
// Teredo 2001:0000::/32 — last 32 bits = client IPv4 XOR 0xffffffff.
|
|
if (g[0] === 0x2001 && g[1] === 0x0000) return v4(g[6] ^ 0xffff, g[7] ^ 0xffff);
|
|
return null;
|
|
}
|
|
|
|
/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */
|
|
function expandV6(ip: string): number[] | null {
|
|
let h = ip.toLowerCase().replace(/%.*$/, '');
|
|
if (!h.includes(':')) return null;
|
|
const dotted = h.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
if (dotted) {
|
|
const v4 = dotted[2].split('.').map(Number);
|
|
if (v4.some((n) => n > 255)) return null;
|
|
h = dotted[1] + ((v4[0] << 8) | v4[1]).toString(16) + ':' + ((v4[2] << 8) | v4[3]).toString(16);
|
|
}
|
|
const halves = h.split('::');
|
|
if (halves.length > 2) return null;
|
|
const head = halves[0] ? halves[0].split(':') : [];
|
|
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
const groups =
|
|
halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
|
|
if (groups.length !== 8) return null;
|
|
const nums = groups.map((x) => (x === '' ? Number.NaN : Number.parseInt(x, 16)));
|
|
return nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff) ? null : nums;
|
|
}
|
|
|
|
/** Build a declared-egress host matcher (exact host or `*.suffix` wildcard). */
|
|
export function makeHostAllow(egress: string[]): (host: string) => boolean {
|
|
const patterns = egress
|
|
.map((h) => h.trim().toLowerCase().replace(/\.$/, '')) // drop a trailing dot (FQDN trick)
|
|
.filter((p) => p && p !== '*')
|
|
// A wildcard must have a real multi-label suffix — reject the degenerate
|
|
// `*.` (matched any trailing-dot host = allow-all) and whole-TLD `*.com`.
|
|
.filter((p) => !p.startsWith('*.') || p.slice(2).includes('.'));
|
|
return (host: string) => {
|
|
const h = host.toLowerCase().replace(/\.$/, '');
|
|
return patterns.some((p) => (p.startsWith('*.') ? h === p.slice(2) || h.endsWith(p.slice(1)) : h === p));
|
|
};
|
|
}
|
|
|
|
export interface ConnectTarget {
|
|
kind: 'local' | 'literal-ip' | 'hostname';
|
|
host: string;
|
|
}
|
|
|
|
/**
|
|
* Destination host of a `dgram.Socket.send(msg[, offset, length], port[, address][, cb])`
|
|
* call, or null when no explicit address is given (Node then uses the socket's
|
|
* connected remote or the localhost default — the connect() wrapper already
|
|
* vetted that, and a localhost default is not an exfil vector). Only an explicit
|
|
* address string is a target we must allowlist. Mirrors both send() overloads:
|
|
* the (offset, length) form pushes port/address two slots right.
|
|
*/
|
|
export function dgramSendTarget(args: unknown[]): string | null {
|
|
const offsetForm = typeof args[1] === 'number' && typeof args[2] === 'number';
|
|
const address = args[offsetForm ? 4 : 2];
|
|
return typeof address === 'string' ? address : null;
|
|
}
|
|
|
|
/**
|
|
* Destination host of a `dgram.Socket.connect(port[, address][, cb])` call, or
|
|
* null when no address is given (localhost default — not an exfil vector).
|
|
*/
|
|
export function dgramConnectTarget(args: unknown[]): string | null {
|
|
return typeof args[1] === 'string' ? (args[1] as string) : null;
|
|
}
|
|
|
|
/**
|
|
* Node's `net.connect()` pre-normalises its arguments into an `[options, callback]`
|
|
* array and then calls `Socket.prototype.connect` with THAT ARRAY as the single
|
|
* argument. undici's plain-HTTP connector takes this path; its TLS connector does
|
|
* not — which is why HTTPS egress worked and plain HTTP silently did not.
|
|
*
|
|
* Unwrap it before anything reads `host`/`path`, or those come back undefined off an
|
|
* array, every plain-HTTP request a plugin makes is misclassified as `localhost`, and
|
|
* it is rejected with a nonsense "localhost is not in the plugin's declared hosts".
|
|
*/
|
|
export function unwrapConnectArgs(args: unknown[]): unknown[] {
|
|
return Array.isArray(args[0]) ? (args[0] as unknown[]) : args;
|
|
}
|
|
|
|
/**
|
|
* Classify a `net.Socket.connect(...)` argument list into what we must check:
|
|
* a unix-socket/pipe (local, allowed), a literal IP (checked synchronously), or
|
|
* a hostname (allowlist + a DNS-resolving guard). Mirrors Node's connect
|
|
* overloads: connect(options[,cb]) | connect(port[,host][,cb]) | connect(path[,cb]),
|
|
* plus the pre-normalised array form above.
|
|
*/
|
|
export function classifyConnect(rawArgs: unknown[], isIP: (s: string) => boolean): ConnectTarget {
|
|
const args = unwrapConnectArgs(rawArgs);
|
|
const first = args[0];
|
|
if (first && typeof first === 'object') {
|
|
const o = first as { host?: string; path?: string };
|
|
if (o.path) return { kind: 'local', host: o.path };
|
|
const host = o.host ?? 'localhost';
|
|
return { kind: isIP(host) ? 'literal-ip' : 'hostname', host };
|
|
}
|
|
if (typeof first === 'string' && !/^\d+$/.test(first)) {
|
|
// a bare string that isn't a port number is an IPC path
|
|
return { kind: 'local', host: first };
|
|
}
|
|
// connect(port[, host][, cb])
|
|
const host = typeof args[1] === 'string' ? (args[1] as string) : 'localhost';
|
|
return { kind: isIP(host) ? 'literal-ip' : 'hostname', host };
|
|
}
|