ci(security): integrate Bright CI pipeline for security tests and remediation#910
ci(security): integrate Bright CI pipeline for security tests and remediation#910bright-security-golf[bot] wants to merge 18 commits intostablefrom
Conversation
skip-checks:true
skip-checks:true
skip-checks:true
skip-checks:true
skip-checks:true
| await fs.promises.access(file, R_OK); | ||
| try { | ||
| if (file.startsWith('/')) { | ||
| await fs.promises.access(file, R_OK); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
General approach: constrain all filesystem access/deletion to a defined safe root directory on the server, normalize resolved paths, and verify that the final path remains within that root before calling fs APIs. Reject or error out when a user-supplied path points outside the root. This addresses all variants because they all eventually call FileService.getFile (and deleteFile) with user-controlled values.
Concrete fix for this code:
-
In
FileService, define a constant safe base directory, for example a subdirectory underprocess.cwd()(e.g.FILES_ROOT = path.resolve(process.cwd(), 'files')). Since we cannot assume other project config, usingprocess.cwd()plus a fixed subfolder keeps behavior close to current relative-path usage while adding a boundary. -
Implement a small helper (inside
FileService) to resolve a user-supplied path againstFILES_ROOTand enforce containment:- Use
path.resolve(FILES_ROOT, file)to normalize. - Optionally call
fs.realpathSyncorfs.promises.realpathon the resolved path to collapse symlinks (we’ll use the asyncfs.promises.realpathto stay within async flow). - Check that the canonical path starts with
FILES_ROOT + path.sepor equalsFILES_ROOT. If not, throw an error.
- Use
-
Update
getFile:- Keep the
httpbranch as-is for remote/cloud objects. - Remove the branch that trusts absolute paths starting with
/, because that allows access anywhere. Instead, treat any non-httpinput as a logical relative path underFILES_ROOT:- Compute
const safePath = await this.getSafeLocalPath(file); - Use
fs.promises.access(safePath, R_OK)andfs.createReadStream(safePath).
- Compute
- This preserves functionality for relative paths but removes arbitrary absolute path access.
- Keep the
-
Update
deleteFilesimilarly:- Forbid deletion of
http/cloud resources as before. - Treat any non-
httpvalue as a relative path underFILES_ROOT:- Compute
const safePath = await this.getSafeLocalPath(file); - Call
fs.promises.unlink(safePath).
- Compute
- Forbid deletion of
-
Error handling: reuse the existing
try/catchblocks inFileService; if the path is outside the root,getSafeLocalPaththrows an error caught and wrapped intoInternalServerErrorException, preserving the controller contracts.
No changes are required in FileController to fix the core issue, because once FileService validates and constrains filesystem paths, all controller variants that call getFile or deleteFile will benefit automatically.
| return fs.createReadStream(file); | ||
| } else if (file.startsWith('http')) { | ||
| const content = await this.cloudProviders.get(file); | ||
| return fs.createReadStream(file); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
To fix this, we need to ensure that filesystem paths derived from user input are constrained to a safe root directory and cannot escape it via .., symlinks, or absolute paths. For URLs (HTTP/HTTPS) we can continue to delegate to CloudProvidersMetaData as today.
The best approach with minimal functional change is:
- Define a fixed base directory for local file operations, e.g.
const LOCAL_ROOT = path.resolve(process.cwd(), '');(or a more specific subfolder if your app uses one). - For any non-HTTP path in
getFileanddeleteFile:- If it is absolute, resolve it relative to
LOCAL_ROOT(i.e. disallow arbitrary absolute paths). - Use
path.resolve(LOCAL_ROOT, userInput)to normalize and join. - Optionally, call
fs.realpathSync/fs.promises.realpathto resolve symlinks. - After resolving, verify the resulting path is still under
LOCAL_ROOTwith a prefix check (e.g.if (!resolvedPath.startsWith(LOCAL_ROOT + path.sep))). - If the check fails, throw a
BadRequestExceptionorInternalServerErrorExceptionindicating an invalid path, instead of accessing the filesystem.
- If it is absolute, resolve it relative to
- Use the sanitized path for
fs.promises.access,fs.createReadStream, andfs.promises.unlink. - For write operations in the controller (
saveRawContentsnippet around lines 330–336), we should also constrain the destination path similarly: resolve against the sameLOCAL_ROOT, check it stays within that root, thenaccess(dirname)andwriteFileusing the safe path.
Because instructions restrict edits to src/file/file.service.ts and/or src/file/file.controller.ts, we will:
- Add a private helper method in
FileServiceto sanitize local filesystem paths (it can be used bygetFileanddeleteFile). - Optionally add a small helper in
FileControllerto sanitize paths used insaveRawContent(if that method exists in the omitted sections and uses user-providedfilepath; we only have the lower snippet, so we’ll only change what is visible: constrainfilebeforeaccess/writeFile). - Keep existing behavior for HTTP URLs and cloud-provider-prefixed paths, which don’t hit the filesystem directly.
Concretely:
- In
src/file/file.service.ts, introduceprivate readonly LOCAL_ROOT = path.resolve(process.cwd());and aprivate resolveLocalPath(untrusted: string): string:- Reject any path that starts with
http(already handled) or with..segments that would traverse outside root. - Use
path.resolve(this.LOCAL_ROOT, untrusted)and thenfs.realpathSync(sync is acceptable for a small single call; otherwise we could useawait fs.promises.realpath). - Check that the final real path starts with
this.LOCAL_ROOT + path.sep(or equalsthis.LOCAL_ROOT).
- Reject any path that starts with
- Change
getFile:- Remove the branch that allows arbitrary absolute
/paths. - For non-HTTP paths, call
this.resolveLocalPath(file)into e.g.const safePath = this.resolveLocalPath(file);, then runaccessandcreateReadStreamonsafePath.
- Remove the branch that allows arbitrary absolute
- Change
deleteFilesimilarly: usethis.resolveLocalPath(file)andunlinkthat safe path; remove the explicit absolute path/HTTP handling (or keep those branches but ensure they cannot be abused). - In
src/file/file.controller.ts, in thesaveRawContent-like method around lines 330–336, we have:We will:await fs.promises.access(path.dirname(file), W_OK); await fs.promises.writeFile(file, raw);
- Resolve
fileagainst a root (we can reuseprocess.cwd()here, similar to the service) and ensure it does not escape, using the same normalization and prefix check pattern. - Use the sanitized
safeFileboth indirname/accessandwriteFile.
- Resolve
No new external dependencies are required; we will rely on Node’s built-in path and fs modules that are already imported.
| @@ -330,9 +330,15 @@ | ||
| ): Promise<string> { | ||
| try { | ||
| if (typeof raw === 'string' || Buffer.isBuffer(raw)) { | ||
| await fs.promises.access(path.dirname(file), W_OK); | ||
| await fs.promises.writeFile(file, raw); | ||
| return `File uploaded successfully at ${file}`; | ||
| const root = path.resolve(process.cwd()); | ||
| const resolvedFile = path.resolve(root, file); | ||
| const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; | ||
| if (resolvedFile !== root && !resolvedFile.startsWith(rootWithSep)) { | ||
| throw new BadRequestException(`Invalid file path '${file}'`); | ||
| } | ||
| await fs.promises.access(path.dirname(resolvedFile), W_OK); | ||
| await fs.promises.writeFile(resolvedFile, raw); | ||
| return `File uploaded successfully at ${resolvedFile}`; | ||
| } | ||
| } catch (err) { | ||
| this.logger.error(err.message); |
| file = path.resolve(process.cwd(), file); | ||
|
|
||
| await fs.promises.access(file, R_OK); | ||
| await fs.promises.access(file, R_OK); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, to fix uncontrolled path usage you must (a) decide on a safe root directory for filesystem operations, (b) normalize any user-supplied relative path against that root using path.resolve, and (c) verify that the resulting absolute path is still inside the root (e.g. by checking startsWith(root + path.sep) or equivalent). If the check fails, reject the request. For absolute paths, you typically should not allow arbitrary user choice at all unless you have a very strict whitelist.
For this codebase, the most targeted, non‑breaking fix is:
- Introduce a private constant in
FileServicerepresenting the allowed local root (e.g.LOCAL_ROOT = process.cwd();). This keeps current behavior (using the working directory) but allows for the containment check. - In
getFile:- Disallow arbitrary absolute filesystem paths coming from the client: remove or harden the
if (file.startsWith('/'))branch. The safest change without altering high-level behavior is to treat all non-HTTP paths as relative toLOCAL_ROOT, resolve them, and confirm they stay under that root. - After
path.resolve, computeconst resolved = path.resolve(this.LOCAL_ROOT, file);(or similar), and check thatresolvedstarts withthis.LOCAL_ROOT(taking care to handle separators so/var/wwwXis not accepted when the root is/var/www). - If the check fails, throw an error (which will be mapped to
InternalServerErrorException('An error occurred while accessing the file.'); you could alternatively throw a more specific Nest exception, but to minimize functional change we just reuse the existing error pathway). - Use
resolvedinfs.promises.accessandfs.createReadStream.
- Disallow arbitrary absolute filesystem paths coming from the client: remove or harden the
- In
deleteFile:- Mirror the same pattern: always resolve against
LOCAL_ROOT, check that the resolved path stays underLOCAL_ROOT, and then callfs.promises.unlinkon the resolved path only if it passes. - Continue rejecting
filevalues that start with/orhttp, as the current code already does.
- Mirror the same pattern: always resolve against
- No changes are required in
FileControllerfor this vulnerability, because centralizing validation inFileService(the sink) will address all the listed CodeQL flows, including variants 1a–1e, since they all funnel throughgetFile.
This approach addresses all 6 variants because any path or file string, regardless of which controller method it came from, is forced through the same normalization and containment check inside FileService.
| @@ -9,16 +9,14 @@ | ||
| export class FileService { | ||
| private readonly logger = new Logger(FileService.name); | ||
| private cloudProviders = new CloudProvidersMetaData(); | ||
| // Root directory for all local file system operations | ||
| private readonly LOCAL_ROOT = process.cwd(); | ||
|
|
||
| async getFile(file: string): Promise<Readable> { | ||
| this.logger.log(`Reading file: ${file}`); | ||
|
|
||
| try { | ||
| if (file.startsWith('/')) { | ||
| await fs.promises.access(file, R_OK); | ||
|
|
||
| return fs.createReadStream(file); | ||
| } else if (file.startsWith('http')) { | ||
| if (file.startsWith('http')) { | ||
| const content = await this.cloudProviders.get(file); | ||
|
|
||
| if (content) { | ||
| @@ -27,11 +20,15 @@ | ||
| throw new Error(`no such file or directory, access '${file}'`); | ||
| } | ||
| } else { | ||
| file = path.resolve(process.cwd(), file); | ||
| const resolvedPath = path.resolve(this.LOCAL_ROOT, file); | ||
|
|
||
| await fs.promises.access(file, R_OK); | ||
| if (!resolvedPath.startsWith(this.LOCAL_ROOT + path.sep) && resolvedPath !== this.LOCAL_ROOT) { | ||
| throw new Error(`Access to path outside of allowed root is denied: '${file}'`); | ||
| } | ||
|
|
||
| return fs.createReadStream(file); | ||
| await fs.promises.access(resolvedPath, R_OK); | ||
|
|
||
| return fs.createReadStream(resolvedPath); | ||
| } | ||
| } catch (err) { | ||
| this.logger.error(err.message); | ||
| @@ -46,8 +41,13 @@ | ||
| } else if (file.startsWith('http')) { | ||
| throw new Error('cannot delete file from this location'); | ||
| } else { | ||
| file = path.resolve(process.cwd(), file); | ||
| await fs.promises.unlink(file); | ||
| const resolvedPath = path.resolve(this.LOCAL_ROOT, file); | ||
|
|
||
| if (!resolvedPath.startsWith(this.LOCAL_ROOT + path.sep) && resolvedPath !== this.LOCAL_ROOT) { | ||
| throw new Error(`Deletion of path outside of allowed root is denied: '${file}'`); | ||
| } | ||
|
|
||
| await fs.promises.unlink(resolvedPath); | ||
| return true; | ||
| } | ||
| } catch (err) { |
| await fs.promises.access(file, R_OK); | ||
|
|
||
| return fs.createReadStream(file); | ||
| return fs.createReadStream(file); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, to fix this type of issue you must validate or constrain user-controlled paths before using them in filesystem operations. For local filesystem access, the common pattern is: define a fixed root directory for allowed files, resolve the user input relative to that root (normalizing .. segments), and then verify the resulting absolute path is still under the root. Only then should you call fs.access, fs.createReadStream, or fs.unlink. Requests for paths outside the root should be rejected.
The best targeted fix here is to add a single reusable helper method inside FileService that safely resolves and validates local file paths against a chosen root (e.g. process.cwd()), and then use that helper in both getFile and deleteFile for the non-HTTP, non-absolute branch. Specifically:
- Add a private method
resolveAndValidateLocalPath(file: string): stringinFileServicethat:- Takes the user-provided relative path.
- Computes
const root = process.cwd(); - Computes
const resolvedPath = path.resolve(root, file); - Optionally uses
fs.realpathSyncto resolve symlinks:const realPath = fs.realpathSync(resolvedPath); - Ensures
realPath.startsWith(root + path.sep)orrealPath === root. If the check fails, it throws an error (orBadRequestException/InternalServerErrorExceptionas you prefer). - Returns the validated
realPath.
- In
getFile, replacefile = path.resolve(process.cwd(), file);withfile = this.resolveAndValidateLocalPath(file);. - In
deleteFile, replacefile = path.resolve(process.cwd(), file);withfile = this.resolveAndValidateLocalPath(file);.
This keeps existing functionality (still reading/writing under the working directory) but prevents user input from escaping that directory via .. or similar tricks. It also fixes all CodeQL variants focused on the filesystem sink, since they all ultimately flow into getFile and, for deletion, into deleteFile. No changes are required in file.controller.ts for this specific alert, because the controller already routes all these paths through FileService.getFile / deleteFile, and the dangerous behavior is in FileService.
| @@ -10,6 +10,22 @@ | ||
| private readonly logger = new Logger(FileService.name); | ||
| private cloudProviders = new CloudProvidersMetaData(); | ||
|
|
||
| /** | ||
| * Resolve a user-supplied local file path against the application root | ||
| * directory and ensure that the resulting path does not escape this root. | ||
| */ | ||
| private resolveAndValidateLocalPath(file: string): string { | ||
| const root = process.cwd(); | ||
| const resolvedPath = path.resolve(root, file); | ||
| const realPath = fs.realpathSync(resolvedPath); | ||
|
|
||
| if (realPath !== root && !realPath.startsWith(root + path.sep)) { | ||
| throw new Error(`Invalid file path '${file}'`); | ||
| } | ||
|
|
||
| return realPath; | ||
| } | ||
|
|
||
| async getFile(file: string): Promise<Readable> { | ||
| this.logger.log(`Reading file: ${file}`); | ||
|
|
||
| @@ -27,7 +43,7 @@ | ||
| throw new Error(`no such file or directory, access '${file}'`); | ||
| } | ||
| } else { | ||
| file = path.resolve(process.cwd(), file); | ||
| file = this.resolveAndValidateLocalPath(file); | ||
|
|
||
| await fs.promises.access(file, R_OK); | ||
|
|
||
| @@ -46,7 +62,7 @@ | ||
| } else if (file.startsWith('http')) { | ||
| throw new Error('cannot delete file from this location'); | ||
| } else { | ||
| file = path.resolve(process.cwd(), file); | ||
| file = this.resolveAndValidateLocalPath(file); | ||
| await fs.promises.unlink(file); | ||
| return true; | ||
| } |
| throw new Error('cannot delete file from this location'); | ||
| } else { | ||
| file = path.resolve(process.cwd(), file); | ||
| await fs.promises.unlink(file); |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, the fix is to constrain file operations to a defined safe root directory and verify that any path derived from user input, once normalized, still resides under that root. This is typically done by defining a base directory (e.g., a storage or uploads directory), combining it with the user input using path.resolve, and then checking that the resolved absolute path begins with the base directory path. If the check fails, the operation should be rejected.
For this specific code, the best minimal change is to:
- Define a constant
ROOT_DIRinFileServicerepresenting the directory under which deletions are allowed (for example,process.cwd()or a subdirectory of it). - When deleting, resolve the user-provided
fileagainstROOT_DIRinstead of directly againstprocess.cwd(). - After resolving, verify that the resulting absolute path starts with
ROOT_DIR + path.sep(or exactly equalsROOT_DIR), and throw an error if it does not. - Call
fs.promises.unlinkonly on the validated resolved path.
Concretely, in src/file/file.service.ts, add a private readonly ROOT_DIR field (initialized to process.cwd() to preserve behavior while making the constraint explicit). Then, in deleteFile, replace file = path.resolve(process.cwd(), file); with construction of const resolvedPath = path.resolve(this.ROOT_DIR, file);, followed by a check that resolvedPath is inside this.ROOT_DIR. Use resolvedPath in the unlink call instead of the original file. No additional external dependencies are required; the existing path and fs imports are sufficient.
| @@ -9,6 +9,7 @@ | ||
| export class FileService { | ||
| private readonly logger = new Logger(FileService.name); | ||
| private cloudProviders = new CloudProvidersMetaData(); | ||
| private readonly ROOT_DIR = process.cwd(); | ||
|
|
||
| async getFile(file: string): Promise<Readable> { | ||
| this.logger.log(`Reading file: ${file}`); | ||
| @@ -46,8 +47,11 @@ | ||
| } else if (file.startsWith('http')) { | ||
| throw new Error('cannot delete file from this location'); | ||
| } else { | ||
| file = path.resolve(process.cwd(), file); | ||
| await fs.promises.unlink(file); | ||
| const resolvedPath = path.resolve(this.ROOT_DIR, file); | ||
| if (!resolvedPath.startsWith(this.ROOT_DIR + path.sep) && resolvedPath !== this.ROOT_DIR) { | ||
| throw new Error('cannot delete file from this location'); | ||
| } | ||
| await fs.promises.unlink(resolvedPath); | ||
| return true; | ||
| } | ||
| } catch (err) { |
| } | ||
|
|
||
| private sanitizeInput(input: string): string { | ||
| return input.replace(/'/g, "\'"); |
Check warning
Code scanning / CodeQL
Replacement of a substring with itself Medium
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, to fix this kind of issue you must ensure the replacement string is actually different from the matched substring and does what the sanitization is supposed to do (e.g., escape, remove, or encode the character). Using \' inside a double-quoted string does not change a single quote; if we want an escaped single quote, we must represent it in a way that is distinguishable in the resulting string (for example, prefixing it with a backslash, or encoding it as an HTML or XML entity).
The minimal fix that preserves existing behavior as much as possible while making sanitizeInput actually sanitize is to escape single quotes by prefixing them with a backslash. In JavaScript/TypeScript source, that means the replacement string should be "\\'": the first backslash escapes the second in the source, and the resulting runtime string is \'. Thus, ' becomes \' in the returned string, which is a common form of escaping for many contexts. Concretely, in src/partners/partners.service.ts, update line 95 from input.replace(/'/g, "\'") to input.replace(/'/g, "\\'"). No additional imports or helper methods are required.
| @@ -92,6 +92,6 @@ | ||
| } | ||
|
|
||
| private sanitizeInput(input: string): string { | ||
| return input.replace(/'/g, "\'"); | ||
| return input.replace(/'/g, "\\'"); | ||
| } | ||
| } |
Note
Fixed 14 of 18 vulnerabilities.
Please review the fixes before merging.
GET /api/partners/partnerLoginPOST /api/renderPOST /api/metadataPOST /api/process_numbersGET /api/partners/searchPartnersGET /api/file/googleGET /api/users/id/1GET /api/file/digital_oceanGET /api/file/azureGET /api/file/awsPOST /api/metadataDELETE /api/fileGET /api/secretsGET /api/testimonials/countGET /api/products/latestGET /api/gotoWorkflow execution details