-
-
Notifications
You must be signed in to change notification settings - Fork 4.8k
fix(server): close critical type-confusion + add validation helper module #1317
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
magyargergo
merged 3 commits into
main
from
fix/server-validation-helper-and-type-confusion
May 4, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
d02dfb2
fix(server): close js/type-confusion-through-parameter-tampering at /…
magyargergo 3756d7c
fix(server): close js/regex-injection at /api/grep — literal substrin…
magyargergo 5f6307e
Potential fix for pull request finding 'CodeQL / Regular expression i…
magyargergo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| /** | ||
| * Server-side input validation helpers. | ||
| * | ||
| * Convention: helpers throw BadRequestError (or its 403 subclass ForbiddenError) | ||
| * when user input fails validation. Existing route handlers wrap their bodies in | ||
| * try/catch and translate the error to res.status(err.status).json({error: err.message}). | ||
| * This pattern was chosen over an asyncHandler middleware to stay compatible with | ||
| * Express 4's non-propagation of async-thrown errors and to match the existing | ||
| * try/catch shape used throughout api.ts. | ||
| * | ||
| * Scope (this PR — U1 of the security remediation plan): | ||
| * - assertString: closes js/type-confusion-through-parameter-tampering (api.ts:1118) | ||
| * - assertSafePath: consolidates the path-traversal guard from api.ts:1067-1077 | ||
| * for reuse across other path-injection findings (U2/U3) | ||
| * - escapeRegExp: utility for upcoming regex-injection fix at /api/grep (U5) | ||
| * | ||
| * Helpers added in later units (U3 git-clone hardening, U4 rate-limiting) live | ||
| * in this module too but are introduced with the dependency they require. | ||
| */ | ||
|
|
||
| import path from 'node:path'; | ||
|
|
||
| /** | ||
| * Thrown by validation helpers when user input is rejected. | ||
| * Routes catch via existing try/catch and convert with err.status / err.message. | ||
| */ | ||
| export class BadRequestError extends Error { | ||
| readonly status: number; | ||
| constructor(message: string, status = 400) { | ||
| super(message); | ||
| this.name = 'BadRequestError'; | ||
| this.status = status; | ||
| } | ||
| } | ||
|
|
||
| export class ForbiddenError extends BadRequestError { | ||
| constructor(message: string) { | ||
| super(message, 403); | ||
| this.name = 'ForbiddenError'; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Type guard for HTTP request parameters that must be a single string. | ||
| * | ||
| * Express's req.query and req.body parsers return `string | string[] | ParsedQs` | ||
| * for any field, but route handlers commonly cast to `string` and operate on | ||
| * `.length`. When the caller passes the same key twice (?x=a&x=b) the value | ||
| * arrives as an array, and a `.length` check intended for the string ends up | ||
| * counting array elements — bypassing length-based guards (CodeQL | ||
| * js/type-confusion-through-parameter-tampering, alert at api.ts:1118). | ||
| * | ||
| * @throws BadRequestError when value is not a string (array, object, undefined, etc.) | ||
| */ | ||
| export function assertString(value: unknown, fieldName: string): string { | ||
| if (typeof value !== 'string') { | ||
| if (Array.isArray(value)) { | ||
| throw new BadRequestError(`Parameter "${fieldName}" must be a single string, got an array`); | ||
| } | ||
| throw new BadRequestError(`Parameter "${fieldName}" must be a string`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a user-supplied relative path against an allowed root and verify it | ||
| * stays inside that root. Mirrors the existing guard at api.ts:1067-1077. | ||
| * | ||
| * Returns the absolute resolved path. Rejects empty paths, null bytes, and | ||
| * paths that resolve outside the root (e.g., `../../../etc/passwd`). | ||
| * | ||
| * @throws BadRequestError when the path is empty or contains a null byte | ||
| * @throws ForbiddenError when the resolved path escapes the root | ||
| */ | ||
| export function assertSafePath(rawPath: string, root: string): string { | ||
| if (rawPath.length === 0) { | ||
| throw new BadRequestError('Path must not be empty'); | ||
| } | ||
| if (rawPath.includes('\0')) { | ||
| throw new BadRequestError('Path must not contain null bytes'); | ||
| } | ||
| const resolvedRoot = path.resolve(root); | ||
| const fullPath = path.resolve(resolvedRoot, rawPath); | ||
| if (fullPath !== resolvedRoot && !fullPath.startsWith(resolvedRoot + path.sep)) { | ||
| throw new ForbiddenError('Path traversal denied'); | ||
| } | ||
| return fullPath; | ||
| } | ||
|
|
||
| /** | ||
| * Escape regex metacharacters in a user-supplied string so it can be safely | ||
| * embedded as a literal in `new RegExp(...)`. Used by /api/grep's literal mode | ||
| * and any future endpoint that constructs a regex from caller input. | ||
| */ | ||
| export function escapeRegExp(input: string): string { | ||
| return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| /** | ||
| * Unit Tests: server validation helpers (gitnexus/src/server/validation.ts) | ||
| * | ||
| * Covers U1 of the security remediation plan: | ||
| * - assertString closes js/type-confusion-through-parameter-tampering by | ||
| * rejecting array-form HTTP query parameters before they reach a `.length` guard. | ||
| * - assertSafePath consolidates the path-traversal guard from api.ts:1067-1077 | ||
| * for reuse across other path-injection findings. | ||
| * - escapeRegExp is the utility for upcoming /api/grep regex-injection fix. | ||
| */ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import path from 'node:path'; | ||
| import { | ||
| assertString, | ||
| assertSafePath, | ||
| escapeRegExp, | ||
| BadRequestError, | ||
| ForbiddenError, | ||
| } from '../../src/server/validation.js'; | ||
|
|
||
| describe('assertString', () => { | ||
| it('returns the value when it is a string', () => { | ||
| expect(assertString('hello', 'name')).toBe('hello'); | ||
| }); | ||
|
|
||
| it('returns an empty string as-is (length validation is the caller’s job)', () => { | ||
| expect(assertString('', 'name')).toBe(''); | ||
| }); | ||
|
|
||
| it('rejects an array with a message naming the field', () => { | ||
| expect(() => assertString(['a', 'b'], 'pattern')).toThrow(BadRequestError); | ||
| try { | ||
| assertString(['a', 'b'], 'pattern'); | ||
| } catch (err) { | ||
| expect(err).toBeInstanceOf(BadRequestError); | ||
| expect((err as BadRequestError).status).toBe(400); | ||
| expect((err as Error).message).toContain('pattern'); | ||
| expect((err as Error).message).toContain('array'); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects undefined', () => { | ||
| expect(() => assertString(undefined, 'name')).toThrow(BadRequestError); | ||
| }); | ||
|
|
||
| it('rejects a number', () => { | ||
| expect(() => assertString(123, 'name')).toThrow(BadRequestError); | ||
| }); | ||
|
|
||
| it('rejects an object', () => { | ||
| expect(() => assertString({ key: 'value' }, 'name')).toThrow(BadRequestError); | ||
| }); | ||
| }); | ||
|
|
||
| describe('assertSafePath', () => { | ||
| const root = path.resolve('/repos/x'); | ||
|
|
||
| it('resolves an in-repo relative path to its absolute form', () => { | ||
| const result = assertSafePath('src/foo.ts', root); | ||
| expect(result).toBe(path.join(root, 'src/foo.ts')); | ||
| }); | ||
|
|
||
| it('accepts the root itself', () => { | ||
| expect(assertSafePath('.', root)).toBe(root); | ||
| }); | ||
|
|
||
| it('rejects a parent-directory traversal with ForbiddenError (status 403)', () => { | ||
| expect(() => assertSafePath('../../../etc/passwd', root)).toThrow(ForbiddenError); | ||
| try { | ||
| assertSafePath('../../../etc/passwd', root); | ||
| } catch (err) { | ||
| expect((err as BadRequestError).status).toBe(403); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects an absolute path that escapes the root', () => { | ||
| expect(() => assertSafePath('/etc/passwd', root)).toThrow(ForbiddenError); | ||
| }); | ||
|
|
||
| it('rejects an empty path', () => { | ||
| expect(() => assertSafePath('', root)).toThrow(BadRequestError); | ||
| }); | ||
|
|
||
| it('rejects a path containing a null byte', () => { | ||
| expect(() => assertSafePath('foo\0bar', root)).toThrow(BadRequestError); | ||
| }); | ||
|
|
||
| it('does not confuse "src/.." with "../" (must not escape root)', () => { | ||
| // src/.. resolves back to root, which is allowed. | ||
| expect(assertSafePath('src/..', root)).toBe(root); | ||
| }); | ||
| }); | ||
|
|
||
| describe('escapeRegExp', () => { | ||
| it('escapes the dot metacharacter', () => { | ||
| expect(escapeRegExp('a.b')).toBe('a\\.b'); | ||
| }); | ||
|
|
||
| it('escapes all common regex metacharacters', () => { | ||
| expect(escapeRegExp('a.b*c+d?e^f$g{h}i(j)k|l[m]n\\o')).toBe( | ||
| 'a\\.b\\*c\\+d\\?e\\^f\\$g\\{h\\}i\\(j\\)k\\|l\\[m\\]n\\\\o', | ||
| ); | ||
| }); | ||
|
|
||
| it('passes through a string with no metacharacters', () => { | ||
| expect(escapeRegExp('plain text')).toBe('plain text'); | ||
| }); | ||
|
|
||
| it('handles an empty string', () => { | ||
| expect(escapeRegExp('')).toBe(''); | ||
| }); | ||
|
|
||
| it('produces a literal-matching regex when fed back to new RegExp', () => { | ||
| const userInput = 'a.b*c'; | ||
| const re = new RegExp(escapeRegExp(userInput)); | ||
| expect(re.test('a.b*c')).toBe(true); | ||
| expect(re.test('axbxc')).toBe(false); // confirms the . was treated as literal | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.