gitnexus 1.6.4-rc.54 → 1.6.4-rc.56
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/dist/core/ingestion/model/resolve.js +31 -11
- package/dist/server/api.d.ts +20 -0
- package/dist/server/api.js +100 -53
- package/dist/server/validation.d.ts +60 -0
- package/dist/server/validation.js +91 -0
- package/package.json +1 -1
|
@@ -132,17 +132,25 @@ export function c3Linearize(classId, parentMap, cache, inProgress) {
|
|
|
132
132
|
}
|
|
133
133
|
// Add the direct parents list as the final sequence
|
|
134
134
|
const sequences = [...parentLinearizations, [...directParents]];
|
|
135
|
+
const heads = new Uint32Array(sequences.length); // head pointer per sequence
|
|
135
136
|
const result = [];
|
|
137
|
+
// Tail-count map: how many sequences contain this id at index > head.
|
|
138
|
+
// O(1) membership check replaces O(n) indexOf scans.
|
|
139
|
+
const tailCount = new Map();
|
|
140
|
+
for (const seq of sequences) {
|
|
141
|
+
for (let i = 1; i < seq.length; i++) {
|
|
142
|
+
tailCount.set(seq[i], (tailCount.get(seq[i]) ?? 0) + 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
let remaining = sequences.reduce((n, s) => n + s.length, 0);
|
|
136
146
|
let inconsistent = false;
|
|
137
|
-
while (
|
|
138
|
-
// Find a good head: one that doesn't appear in the tail of any other sequence
|
|
147
|
+
while (remaining > 0) {
|
|
139
148
|
let head = null;
|
|
140
|
-
for (
|
|
141
|
-
if (
|
|
149
|
+
for (let si = 0; si < sequences.length; si++) {
|
|
150
|
+
if (heads[si] >= sequences[si].length)
|
|
142
151
|
continue;
|
|
143
|
-
const candidate =
|
|
144
|
-
|
|
145
|
-
if (!inTail) {
|
|
152
|
+
const candidate = sequences[si][heads[si]];
|
|
153
|
+
if ((tailCount.get(candidate) ?? 0) === 0) {
|
|
146
154
|
head = candidate;
|
|
147
155
|
break;
|
|
148
156
|
}
|
|
@@ -152,10 +160,22 @@ export function c3Linearize(classId, parentMap, cache, inProgress) {
|
|
|
152
160
|
break;
|
|
153
161
|
}
|
|
154
162
|
result.push(head);
|
|
155
|
-
//
|
|
156
|
-
for (
|
|
157
|
-
if (
|
|
158
|
-
|
|
163
|
+
// Advance head pointers past the chosen head; update tail counts
|
|
164
|
+
for (let si = 0; si < sequences.length; si++) {
|
|
165
|
+
if (heads[si] >= sequences[si].length)
|
|
166
|
+
continue;
|
|
167
|
+
if (sequences[si][heads[si]] === head) {
|
|
168
|
+
heads[si]++;
|
|
169
|
+
remaining--;
|
|
170
|
+
// promoted was in this sequence's active tail; now it's the new head — remove from tailCount
|
|
171
|
+
if (heads[si] < sequences[si].length) {
|
|
172
|
+
const promoted = sequences[si][heads[si]];
|
|
173
|
+
const prev = tailCount.get(promoted);
|
|
174
|
+
if (prev <= 1)
|
|
175
|
+
tailCount.delete(promoted);
|
|
176
|
+
else
|
|
177
|
+
tailCount.set(promoted, prev - 1);
|
|
178
|
+
}
|
|
159
179
|
}
|
|
160
180
|
}
|
|
161
181
|
}
|
package/dist/server/api.d.ts
CHANGED
|
@@ -48,5 +48,25 @@ export declare const staticCacheControlSetHeaders: (res: express.Response, fileP
|
|
|
48
48
|
export declare const registerWebUI: (app: express.Express, staticDir: string | null) => void;
|
|
49
49
|
export declare const writeNdjsonRecord: (res: express.Response, record: GraphStreamRecord, signal?: AbortSignal) => Promise<void>;
|
|
50
50
|
export declare const streamGraphNdjson: (res: express.Response, includeContent?: boolean, signal?: AbortSignal) => Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Handle a GET /api/file request body. Extracted from createServer's route
|
|
53
|
+
* registration so it can be unit-tested without spinning up an HTTP server
|
|
54
|
+
* — calling app.get(...) inside a test triggers CodeQL's
|
|
55
|
+
* js/missing-rate-limiting query, which is appropriate for production
|
|
56
|
+
* route handlers but a false positive for tests of the handler logic.
|
|
57
|
+
*
|
|
58
|
+
* The function takes the express req and res (typed loosely so test code
|
|
59
|
+
* can pass minimal mocks) plus the resolved repo path. All path-traversal
|
|
60
|
+
* containment is done inline at the readFile sink with the canonical
|
|
61
|
+
* path.relative idiom for CodeQL js/path-injection recognition.
|
|
62
|
+
*/
|
|
63
|
+
export declare const handleFileRequest: (req: {
|
|
64
|
+
query: any;
|
|
65
|
+
}, res: {
|
|
66
|
+
status: (code: number) => {
|
|
67
|
+
json: (body: any) => void;
|
|
68
|
+
};
|
|
69
|
+
json: (body: any) => void;
|
|
70
|
+
}, repoPath: string) => Promise<void>;
|
|
51
71
|
export declare const createServer: (port: number, host?: string) => Promise<void>;
|
|
52
72
|
export {};
|
package/dist/server/api.js
CHANGED
|
@@ -25,6 +25,7 @@ import { mountMCPEndpoints } from './mcp-http.js';
|
|
|
25
25
|
import { fork } from 'child_process';
|
|
26
26
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
27
27
|
import { JobManager } from './analyze-job.js';
|
|
28
|
+
import { assertString, escapeRegExp, BadRequestError } from './validation.js';
|
|
28
29
|
import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
|
|
29
30
|
const _require = createRequire(import.meta.url);
|
|
30
31
|
const pkg = _require('../../package.json');
|
|
@@ -429,6 +430,10 @@ const mountSSEProgress = (app, routePath, jm) => {
|
|
|
429
430
|
});
|
|
430
431
|
};
|
|
431
432
|
const statusFromError = (err) => {
|
|
433
|
+
// Validation helpers throw BadRequestError / ForbiddenError with a typed
|
|
434
|
+
// .status field — honor it before falling back to message-string matching.
|
|
435
|
+
if (err instanceof BadRequestError)
|
|
436
|
+
return err.status;
|
|
432
437
|
const msg = String(err?.message ?? '');
|
|
433
438
|
if (msg.includes('No indexed repositories') || msg.includes('not found'))
|
|
434
439
|
return 404;
|
|
@@ -445,6 +450,77 @@ const requestedRepo = (req) => {
|
|
|
445
450
|
}
|
|
446
451
|
return undefined;
|
|
447
452
|
};
|
|
453
|
+
/**
|
|
454
|
+
* Handle a GET /api/file request body. Extracted from createServer's route
|
|
455
|
+
* registration so it can be unit-tested without spinning up an HTTP server
|
|
456
|
+
* — calling app.get(...) inside a test triggers CodeQL's
|
|
457
|
+
* js/missing-rate-limiting query, which is appropriate for production
|
|
458
|
+
* route handlers but a false positive for tests of the handler logic.
|
|
459
|
+
*
|
|
460
|
+
* The function takes the express req and res (typed loosely so test code
|
|
461
|
+
* can pass minimal mocks) plus the resolved repo path. All path-traversal
|
|
462
|
+
* containment is done inline at the readFile sink with the canonical
|
|
463
|
+
* path.relative idiom for CodeQL js/path-injection recognition.
|
|
464
|
+
*/
|
|
465
|
+
export const handleFileRequest = async (req, res, repoPath) => {
|
|
466
|
+
try {
|
|
467
|
+
// Type-confusion guard — req.query.path is `string | string[] | ParsedQs`.
|
|
468
|
+
// Without this, an attacker could pass `?path=a&path=b` to bypass the
|
|
469
|
+
// length-bound traversal check below (CodeQL js/type-confusion-through-
|
|
470
|
+
// parameter-tampering, same class as the /api/grep critical fix).
|
|
471
|
+
const rawFilePath = req.query.path;
|
|
472
|
+
if (rawFilePath === undefined || rawFilePath === '') {
|
|
473
|
+
res.status(400).json({ error: 'Missing path' });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const filePath = assertString(rawFilePath, 'path');
|
|
477
|
+
// Path-injection containment — inline at the sink with the canonical
|
|
478
|
+
// path.relative idiom that CodeQL's js/path-injection sanitizer
|
|
479
|
+
// recognizes. assertSafePath in validation.ts performs the equivalent
|
|
480
|
+
// check, but cross-module helpers are not followed by CodeQL's
|
|
481
|
+
// interprocedural analysis for path-traversal sanitization in JS, so
|
|
482
|
+
// the barrier must be visible inline at the readFile sink.
|
|
483
|
+
const repoRoot = path.resolve(repoPath);
|
|
484
|
+
const fullPath = path.resolve(repoRoot, filePath);
|
|
485
|
+
const fullRel = path.relative(repoRoot, fullPath);
|
|
486
|
+
if (fullRel.startsWith('..') || path.isAbsolute(fullRel)) {
|
|
487
|
+
res.status(403).json({ error: 'Path traversal denied' });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
491
|
+
// Optional line-range support: ?startLine=10&endLine=50
|
|
492
|
+
// Returns only the requested slice (0-indexed), plus metadata.
|
|
493
|
+
const startLine = req.query.startLine !== undefined ? Number(req.query.startLine) : undefined;
|
|
494
|
+
const endLine = req.query.endLine !== undefined ? Number(req.query.endLine) : undefined;
|
|
495
|
+
if (startLine !== undefined && Number.isFinite(startLine)) {
|
|
496
|
+
const lines = raw.split('\n');
|
|
497
|
+
const start = Math.max(0, startLine);
|
|
498
|
+
const end = endLine !== undefined && Number.isFinite(endLine)
|
|
499
|
+
? Math.min(lines.length, endLine + 1)
|
|
500
|
+
: lines.length;
|
|
501
|
+
res.json({
|
|
502
|
+
content: lines.slice(start, end).join('\n'),
|
|
503
|
+
startLine: start,
|
|
504
|
+
endLine: end - 1,
|
|
505
|
+
totalLines: lines.length,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
res.json({ content: raw, totalLines: raw.split('\n').length });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
if (err.code === 'ENOENT') {
|
|
514
|
+
res.status(404).json({ error: 'File not found' });
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
// statusFromError returns err.status for BadRequestError / ForbiddenError
|
|
518
|
+
// (assertString → 400 on array-form ?path=a&path=b; ForbiddenError → 403
|
|
519
|
+
// on traversal). Falls back to 500 for unrecognized failures.
|
|
520
|
+
res.status(statusFromError(err)).json({ error: err.message || 'Failed to read file' });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
};
|
|
448
524
|
export const createServer = async (port, host = '127.0.0.1') => {
|
|
449
525
|
const app = express();
|
|
450
526
|
app.disable('x-powered-by');
|
|
@@ -910,54 +986,12 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
910
986
|
});
|
|
911
987
|
// Read file — with path traversal guard
|
|
912
988
|
app.get('/api/file', async (req, res) => {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
return;
|
|
918
|
-
}
|
|
919
|
-
const filePath = req.query.path;
|
|
920
|
-
if (!filePath) {
|
|
921
|
-
res.status(400).json({ error: 'Missing path' });
|
|
922
|
-
return;
|
|
923
|
-
}
|
|
924
|
-
// Prevent path traversal — resolve and verify the path stays within the repo root
|
|
925
|
-
const repoRoot = path.resolve(entry.path);
|
|
926
|
-
const fullPath = path.resolve(repoRoot, filePath);
|
|
927
|
-
if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) {
|
|
928
|
-
res.status(403).json({ error: 'Path traversal denied' });
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
932
|
-
// Optional line-range support: ?startLine=10&endLine=50
|
|
933
|
-
// Returns only the requested slice (0-indexed), plus metadata.
|
|
934
|
-
const startLine = req.query.startLine !== undefined ? Number(req.query.startLine) : undefined;
|
|
935
|
-
const endLine = req.query.endLine !== undefined ? Number(req.query.endLine) : undefined;
|
|
936
|
-
if (startLine !== undefined && Number.isFinite(startLine)) {
|
|
937
|
-
const lines = raw.split('\n');
|
|
938
|
-
const start = Math.max(0, startLine);
|
|
939
|
-
const end = endLine !== undefined && Number.isFinite(endLine)
|
|
940
|
-
? Math.min(lines.length, endLine + 1)
|
|
941
|
-
: lines.length;
|
|
942
|
-
res.json({
|
|
943
|
-
content: lines.slice(start, end).join('\n'),
|
|
944
|
-
startLine: start,
|
|
945
|
-
endLine: end - 1,
|
|
946
|
-
totalLines: lines.length,
|
|
947
|
-
});
|
|
948
|
-
}
|
|
949
|
-
else {
|
|
950
|
-
res.json({ content: raw, totalLines: raw.split('\n').length });
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
catch (err) {
|
|
954
|
-
if (err.code === 'ENOENT') {
|
|
955
|
-
res.status(404).json({ error: 'File not found' });
|
|
956
|
-
}
|
|
957
|
-
else {
|
|
958
|
-
res.status(500).json({ error: err.message || 'Failed to read file' });
|
|
959
|
-
}
|
|
989
|
+
const entry = await resolveRepo(requestedRepo(req));
|
|
990
|
+
if (!entry) {
|
|
991
|
+
res.status(404).json({ error: 'Repository not found' });
|
|
992
|
+
return;
|
|
960
993
|
}
|
|
994
|
+
await handleFileRequest(req, res, entry.path);
|
|
961
995
|
});
|
|
962
996
|
// Grep — regex search across file contents in the indexed repo
|
|
963
997
|
// Uses filesystem-based search for memory efficiency (never loads all files into memory)
|
|
@@ -968,20 +1002,33 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
968
1002
|
res.status(404).json({ error: 'Repository not found' });
|
|
969
1003
|
return;
|
|
970
1004
|
}
|
|
971
|
-
|
|
972
|
-
|
|
1005
|
+
// Type-confusion guard (CodeQL js/type-confusion-through-parameter-tampering):
|
|
1006
|
+
// req.query.pattern is `string | string[] | ParsedQs` — without an explicit
|
|
1007
|
+
// type check, the `.length` guard below counts array elements instead of
|
|
1008
|
+
// characters, allowing arbitrarily long patterns through.
|
|
1009
|
+
const rawPattern = req.query.pattern;
|
|
1010
|
+
if (rawPattern === undefined) {
|
|
1011
|
+
res.status(400).json({ error: 'Missing "pattern" query parameter' });
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const pattern = assertString(rawPattern, 'pattern');
|
|
1015
|
+
if (pattern.length === 0) {
|
|
973
1016
|
res.status(400).json({ error: 'Missing "pattern" query parameter' });
|
|
974
1017
|
return;
|
|
975
1018
|
}
|
|
976
|
-
//
|
|
1019
|
+
// Length cap: applies to both literal and regex modes as a defense-in-depth
|
|
1020
|
+
// bound against pathological input.
|
|
977
1021
|
if (pattern.length > 200) {
|
|
978
1022
|
res.status(400).json({ error: 'Pattern too long (max 200 characters)' });
|
|
979
1023
|
return;
|
|
980
1024
|
}
|
|
981
|
-
//
|
|
1025
|
+
// Treat user input as a literal substring in all cases to prevent
|
|
1026
|
+
// regex-injection/ReDoS via attacker-controlled regex syntax.
|
|
1027
|
+
const effectivePattern = escapeRegExp(pattern);
|
|
1028
|
+
// Validate regex syntax (catches both opt-in user regex and any escapeRegExp bug)
|
|
982
1029
|
let regex;
|
|
983
1030
|
try {
|
|
984
|
-
regex = new RegExp(
|
|
1031
|
+
regex = new RegExp(effectivePattern, 'gim');
|
|
985
1032
|
}
|
|
986
1033
|
catch {
|
|
987
1034
|
res.status(400).json({ error: 'Invalid regex pattern' });
|
|
@@ -1025,7 +1072,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
1025
1072
|
res.json({ results });
|
|
1026
1073
|
}
|
|
1027
1074
|
catch (err) {
|
|
1028
|
-
res.status(
|
|
1075
|
+
res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' });
|
|
1029
1076
|
}
|
|
1030
1077
|
});
|
|
1031
1078
|
// List all processes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side input validation helpers.
|
|
3
|
+
*
|
|
4
|
+
* Convention: helpers throw BadRequestError (or its 403 subclass ForbiddenError)
|
|
5
|
+
* when user input fails validation. Existing route handlers wrap their bodies in
|
|
6
|
+
* try/catch and translate the error to res.status(err.status).json({error: err.message}).
|
|
7
|
+
* This pattern was chosen over an asyncHandler middleware to stay compatible with
|
|
8
|
+
* Express 4's non-propagation of async-thrown errors and to match the existing
|
|
9
|
+
* try/catch shape used throughout api.ts.
|
|
10
|
+
*
|
|
11
|
+
* Scope (this PR — U1 of the security remediation plan):
|
|
12
|
+
* - assertString: closes js/type-confusion-through-parameter-tampering (api.ts:1118)
|
|
13
|
+
* - assertSafePath: consolidates the path-traversal guard from api.ts:1067-1077
|
|
14
|
+
* for reuse across other path-injection findings (U2/U3)
|
|
15
|
+
* - escapeRegExp: utility for upcoming regex-injection fix at /api/grep (U5)
|
|
16
|
+
*
|
|
17
|
+
* Helpers added in later units (U3 git-clone hardening, U4 rate-limiting) live
|
|
18
|
+
* in this module too but are introduced with the dependency they require.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Thrown by validation helpers when user input is rejected.
|
|
22
|
+
* Routes catch via existing try/catch and convert with err.status / err.message.
|
|
23
|
+
*/
|
|
24
|
+
export declare class BadRequestError extends Error {
|
|
25
|
+
readonly status: number;
|
|
26
|
+
constructor(message: string, status?: number);
|
|
27
|
+
}
|
|
28
|
+
export declare class ForbiddenError extends BadRequestError {
|
|
29
|
+
constructor(message: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Type guard for HTTP request parameters that must be a single string.
|
|
33
|
+
*
|
|
34
|
+
* Express's req.query and req.body parsers return `string | string[] | ParsedQs`
|
|
35
|
+
* for any field, but route handlers commonly cast to `string` and operate on
|
|
36
|
+
* `.length`. When the caller passes the same key twice (?x=a&x=b) the value
|
|
37
|
+
* arrives as an array, and a `.length` check intended for the string ends up
|
|
38
|
+
* counting array elements — bypassing length-based guards (CodeQL
|
|
39
|
+
* js/type-confusion-through-parameter-tampering, alert at api.ts:1118).
|
|
40
|
+
*
|
|
41
|
+
* @throws BadRequestError when value is not a string (array, object, undefined, etc.)
|
|
42
|
+
*/
|
|
43
|
+
export declare function assertString(value: unknown, fieldName: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a user-supplied relative path against an allowed root and verify it
|
|
46
|
+
* stays inside that root. Mirrors the existing guard at api.ts:1067-1077.
|
|
47
|
+
*
|
|
48
|
+
* Returns the absolute resolved path. Rejects empty paths, null bytes, and
|
|
49
|
+
* paths that resolve outside the root (e.g., `../../../etc/passwd`).
|
|
50
|
+
*
|
|
51
|
+
* @throws BadRequestError when the path is empty or contains a null byte
|
|
52
|
+
* @throws ForbiddenError when the resolved path escapes the root
|
|
53
|
+
*/
|
|
54
|
+
export declare function assertSafePath(rawPath: string, root: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Escape regex metacharacters in a user-supplied string so it can be safely
|
|
57
|
+
* embedded as a literal in `new RegExp(...)`. Used by /api/grep's literal mode
|
|
58
|
+
* and any future endpoint that constructs a regex from caller input.
|
|
59
|
+
*/
|
|
60
|
+
export declare function escapeRegExp(input: string): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side input validation helpers.
|
|
3
|
+
*
|
|
4
|
+
* Convention: helpers throw BadRequestError (or its 403 subclass ForbiddenError)
|
|
5
|
+
* when user input fails validation. Existing route handlers wrap their bodies in
|
|
6
|
+
* try/catch and translate the error to res.status(err.status).json({error: err.message}).
|
|
7
|
+
* This pattern was chosen over an asyncHandler middleware to stay compatible with
|
|
8
|
+
* Express 4's non-propagation of async-thrown errors and to match the existing
|
|
9
|
+
* try/catch shape used throughout api.ts.
|
|
10
|
+
*
|
|
11
|
+
* Scope (this PR — U1 of the security remediation plan):
|
|
12
|
+
* - assertString: closes js/type-confusion-through-parameter-tampering (api.ts:1118)
|
|
13
|
+
* - assertSafePath: consolidates the path-traversal guard from api.ts:1067-1077
|
|
14
|
+
* for reuse across other path-injection findings (U2/U3)
|
|
15
|
+
* - escapeRegExp: utility for upcoming regex-injection fix at /api/grep (U5)
|
|
16
|
+
*
|
|
17
|
+
* Helpers added in later units (U3 git-clone hardening, U4 rate-limiting) live
|
|
18
|
+
* in this module too but are introduced with the dependency they require.
|
|
19
|
+
*/
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
/**
|
|
22
|
+
* Thrown by validation helpers when user input is rejected.
|
|
23
|
+
* Routes catch via existing try/catch and convert with err.status / err.message.
|
|
24
|
+
*/
|
|
25
|
+
export class BadRequestError extends Error {
|
|
26
|
+
status;
|
|
27
|
+
constructor(message, status = 400) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'BadRequestError';
|
|
30
|
+
this.status = status;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class ForbiddenError extends BadRequestError {
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(message, 403);
|
|
36
|
+
this.name = 'ForbiddenError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Type guard for HTTP request parameters that must be a single string.
|
|
41
|
+
*
|
|
42
|
+
* Express's req.query and req.body parsers return `string | string[] | ParsedQs`
|
|
43
|
+
* for any field, but route handlers commonly cast to `string` and operate on
|
|
44
|
+
* `.length`. When the caller passes the same key twice (?x=a&x=b) the value
|
|
45
|
+
* arrives as an array, and a `.length` check intended for the string ends up
|
|
46
|
+
* counting array elements — bypassing length-based guards (CodeQL
|
|
47
|
+
* js/type-confusion-through-parameter-tampering, alert at api.ts:1118).
|
|
48
|
+
*
|
|
49
|
+
* @throws BadRequestError when value is not a string (array, object, undefined, etc.)
|
|
50
|
+
*/
|
|
51
|
+
export function assertString(value, fieldName) {
|
|
52
|
+
if (typeof value !== 'string') {
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
throw new BadRequestError(`Parameter "${fieldName}" must be a single string, got an array`);
|
|
55
|
+
}
|
|
56
|
+
throw new BadRequestError(`Parameter "${fieldName}" must be a string`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a user-supplied relative path against an allowed root and verify it
|
|
62
|
+
* stays inside that root. Mirrors the existing guard at api.ts:1067-1077.
|
|
63
|
+
*
|
|
64
|
+
* Returns the absolute resolved path. Rejects empty paths, null bytes, and
|
|
65
|
+
* paths that resolve outside the root (e.g., `../../../etc/passwd`).
|
|
66
|
+
*
|
|
67
|
+
* @throws BadRequestError when the path is empty or contains a null byte
|
|
68
|
+
* @throws ForbiddenError when the resolved path escapes the root
|
|
69
|
+
*/
|
|
70
|
+
export function assertSafePath(rawPath, root) {
|
|
71
|
+
if (rawPath.length === 0) {
|
|
72
|
+
throw new BadRequestError('Path must not be empty');
|
|
73
|
+
}
|
|
74
|
+
if (rawPath.includes('\0')) {
|
|
75
|
+
throw new BadRequestError('Path must not contain null bytes');
|
|
76
|
+
}
|
|
77
|
+
const resolvedRoot = path.resolve(root);
|
|
78
|
+
const fullPath = path.resolve(resolvedRoot, rawPath);
|
|
79
|
+
if (fullPath !== resolvedRoot && !fullPath.startsWith(resolvedRoot + path.sep)) {
|
|
80
|
+
throw new ForbiddenError('Path traversal denied');
|
|
81
|
+
}
|
|
82
|
+
return fullPath;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Escape regex metacharacters in a user-supplied string so it can be safely
|
|
86
|
+
* embedded as a literal in `new RegExp(...)`. Used by /api/grep's literal mode
|
|
87
|
+
* and any future endpoint that constructs a regex from caller input.
|
|
88
|
+
*/
|
|
89
|
+
export function escapeRegExp(input) {
|
|
90
|
+
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
91
|
+
}
|
package/package.json
CHANGED