wave17: 562/240 — vim/qemu/tcl/nodejs/bun/httpd-0002/systemd/emacs

This commit is contained in:
russell@unturf.com 2026-03-27 20:28:18 -04:00
parent cce7ec653a
commit dde5ec97fb
13 changed files with 887 additions and 4 deletions

View file

@ -0,0 +1,64 @@
# nodejs-0001 — CWE-407 in Module._resolveFilename paths dedup
**Severity:** MEDIUM
**File:** `lib/internal/modules/cjs/loader.js`
**Symbol:** `Module._resolveFilename``ArrayPrototypeIncludes(paths, lookupPaths[j])`
## Defect
When `require.resolve(id, { paths: [...] })` is called with an explicit `paths`
array of length P, the implementation deduplicates lookup paths using a linear
scan of the accumulator array:
```js
for (let i = 0; i < options.paths.length; i++) { // O(P)
const lookupPaths = Module._resolveLookupPaths(request, fakeParent); // O(L)
for (let j = 0; j < lookupPaths.length; j++) { // O(L)
if (!ArrayPrototypeIncludes(paths, lookupPaths[j])) { // O(P*L) scan of accumulator
ArrayPrototypePush(paths, lookupPaths[j]);
}
}
}
```
`_resolveLookupPaths` returns up to ~20 node_modules ancestor directories per
input path. The accumulator `paths` grows to P×L entries. Each
`ArrayPrototypeIncludes` call scans the entire accumulator: O(P×L) per check,
called P×L times total → **O(P²×L²)** overall.
At P=50 input paths and L=10 lookup paths per entry, the accumulator reaches
500 entries and the dedup loop performs ~250,000 comparisons instead of ~500.
## Fix
Replace the accumulator array + linear scan with a `Set` for O(1) membership:
```js
const pathsSet = new Set();
const paths = [];
for (let i = 0; i < options.paths.length; i++) {
const path = options.paths[i];
fakeParent.paths = Module._nodeModulePaths(path);
const lookupPaths = Module._resolveLookupPaths(request, fakeParent);
for (let j = 0; j < lookupPaths.length; j++) {
if (!pathsSet.has(lookupPaths[j])) { // O(1)
pathsSet.add(lookupPaths[j]);
ArrayPrototypePush(paths, lookupPaths[j]);
}
}
}
```
## Complexity
| Scenario | Before | After |
|---|---|---|
| P=50 paths, L=10 lookup paths each | O(P²×L²) = ~250k ops | O(P×L) = ~500 ops |
| Ratio at P=50, L=10 | 500× | 1× |
## Source location
`lib/internal/modules/cjs/loader.js``Module._resolveFilename`, the
`ArrayIsArray(options.paths)` / non-relative branch, lines ~14081414.

View file

@ -0,0 +1,173 @@
package unit;
import java.util.*;
/**
* Unit test for nodejs-0001: CWE-407 in Module._resolveFilename paths dedup.
*
* nodejs-0001 (MEDIUM):
* File: lib/internal/modules/cjs/loader.js ~line 1408
* Symbol: Module._resolveFilename ArrayPrototypeIncludes(paths, lookupPaths[j])
* Defect: When require.resolve(id, { paths: [...] }) is called with P explicit
* paths, each generating L lookup paths, the dedup accumulator is a plain Array.
* Every ArrayPrototypeIncludes() scans the entire growing accumulator:
* O(P*L) per check × P*L checks = O(P²×L²) total.
* Fix: Use a Set for the accumulator dedup: O(1) per check, O(P*L) total.
*
* Modeled here in Java:
* JS Array + includes List<String> + contains (defective)
* JS Set + has java.util.Set + contains (fixed)
* Comparison counts tracked at the membership-test site.
*
* Expected at P=50, L=10:
* defective comparisons ~ P²×L²/2 125,000
* fixed comparisons ~ P×L = 500
* ratio > 50×
*/
public class NodejsResolvePathsTest {
// =========================================================================
// Model: _resolveFilename paths dedup
// =========================================================================
/**
* Defective: accumulator is a plain List; every dedup check is a linear scan.
*
* Simulates: for each of P input paths, generate L synthetic lookup paths.
* Dedup into accumulator with List.contains() (O(size) per call).
* All P*L generated paths are unique (worst-case: no early exit).
*/
static long deduplicateDefective(int P, int L) {
long comparisons = 0;
List<String> paths = new ArrayList<>();
for (int i = 0; i < P; i++) {
// Simulate _resolveLookupPaths: L unique paths per input
for (int j = 0; j < L; j++) {
String candidate = "path_" + i + "_" + j;
// ArrayPrototypeIncludes(paths, candidate) O(paths.size()) scan
boolean found = false;
for (String existing : paths) {
comparisons++;
if (existing.equals(candidate)) { found = true; break; }
}
if (!found) {
paths.add(candidate);
}
}
}
return comparisons;
}
/**
* Fixed: companion Set for O(1) membership; same result, far fewer comparisons.
*/
static long deduplicateFixed(int P, int L) {
long comparisons = 0;
List<String> paths = new ArrayList<>();
Set<String> pathsSet = new HashSet<>();
for (int i = 0; i < P; i++) {
for (int j = 0; j < L; j++) {
String candidate = "path_" + i + "_" + j;
// Set.contains(candidate) O(1)
comparisons++;
if (!pathsSet.contains(candidate)) {
pathsSet.add(candidate);
paths.add(candidate);
}
}
}
return comparisons;
}
// =========================================================================
// Tests
// =========================================================================
/** Test 1 — Correctness: both produce the same deduplicated path list. */
static void testCorrectnessMatch() {
int P = 10, L = 5;
List<String> defPaths = new ArrayList<>();
List<String> fixPaths = new ArrayList<>();
Set<String> fixSet = new HashSet<>();
// Rebuild lists without counting (just for correctness check)
for (int i = 0; i < P; i++) {
for (int j = 0; j < L; j++) {
String s = "path_" + i + "_" + j;
if (!defPaths.contains(s)) defPaths.add(s);
if (!fixSet.contains(s)) { fixSet.add(s); fixPaths.add(s); }
}
}
assert defPaths.size() == P * L
: "expected " + (P * L) + " unique paths; got " + defPaths.size();
assert new HashSet<>(defPaths).equals(fixSet)
: "defective and fixed path sets differ";
System.out.println("PASS testCorrectnessMatch");
}
/** Test 2 — nodejs-0001: defective is O(P²×L²), fixed is O(P×L). */
static void testRatioAtScale() {
int P = 50;
int L = 10;
long defComp = deduplicateDefective(P, L);
long fixComp = deduplicateFixed(P, L);
double ratio = (double) defComp / fixComp;
// Defective: accumulator grows 0, 1, 2, ..., (P*L - 1) sum = P*L*(P*L-1)/2
long total = (long) P * L;
long expectedDef = total * (total - 1) / 2;
assert defComp == expectedDef
: "defective comparisons should be P*L*(P*L-1)/2=" + expectedDef
+ "; got " + defComp;
// Fixed: exactly P*L probes (one Set.contains per candidate)
assert fixComp == total
: "fixed comparisons should be P*L=" + total + "; got " + fixComp;
assert ratio >= 50.0
: "ratio should be >=50x at P=50, L=10; got " + ratio;
System.out.printf(
"PASS testRatioAtScale (defective=%d, fixed=%d, ratio=%.1fx)%n",
defComp, fixComp, ratio);
}
/** Test 3 — Duplicate input paths: dedup is idempotent (same output either way). */
static void testWithDuplicateInputPaths() {
// P paths but only P/2 unique base dirs half of the lookupPaths overlap
int P = 20, L = 5;
List<String> defPaths = new ArrayList<>();
Set<String> fixSet = new HashSet<>();
List<String> fixPaths = new ArrayList<>();
for (int i = 0; i < P; i++) {
int base = i % (P / 2); // duplicate base dirs
for (int j = 0; j < L; j++) {
String s = "path_" + base + "_" + j;
if (!defPaths.contains(s)) defPaths.add(s);
if (!fixSet.contains(s)) { fixSet.add(s); fixPaths.add(s); }
}
}
assert new HashSet<>(defPaths).equals(fixSet)
: "duplicate-input path sets differ";
assert defPaths.size() == (P / 2) * L
: "expected " + ((P / 2) * L) + " unique paths with duplicates; got " + defPaths.size();
System.out.println("PASS testWithDuplicateInputPaths");
}
// =========================================================================
public static void main(String[] args) {
testCorrectnessMatch();
testRatioAtScale();
testWithDuplicateInputPaths();
System.out.println("All nodejs-0001 tests passed.");
}
}