refactor(backend): sanitize user input before logging to close CRLF injection (#807)

* refactor(backend): sanitize user input before logging to close CRLF injection

Adds a small sanitizeForLog helper that strips CR, LF, tab, and ASCII
control characters (0x00-0x1F, 0x7F) from a value before it is embedded
in a console.log/warn/error/debug call. Wraps every call site where a
user-controlled value (req.params, req.body, req.query, or a value
derived from them) flows into a log message.

Closes the bulk of the open CodeQL alerts in this family:
- 96 js/log-injection
- 28 js/tainted-format-string

The helper is in backend/src/utils/safeLog.ts. Routes still pre-validate
input at the request boundary; this is the second line of defense and
gives static analyzers a sanitizer they can trace through. JSON
responses, Docker filter labels, and other non-log call sites are
intentionally left unwrapped.

* refactor(backend): printf-style format strings for tainted-log call sites

CodeQL's js/tainted-format-string rule flags template literals in the first
arg of console.X when any interpolated value is user-controlled, regardless
of whether each value is sanitized inline. The canonical mitigation is to
use a static format string and pass values as positional args.

Converts the 28 flagged template literals to printf-style ("%s") format
strings, with sanitizeForLog applied to each positional arg. Also fills in
the log-injection wraps on 9 sites where a user-controlled value was
missed in the first sweep (agents, fleet, gitSources, imageUpdates,
GitSourceService).

No behavior change at runtime. Node's util.format substitutes %s tokens
identically to template-literal interpolation.

* fix(backend): wrap nodeId/snapshotId in fleet restore debug log

CodeQL flagged the unwrapped numeric args even though they cannot
contain control chars in practice. Apply the sanitizer for taint-flow
recognition.
This commit is contained in:
Anso
2026-04-27 10:47:23 -04:00
committed by GitHub
parent 77f27b4bf9
commit 4e5ba17710
32 changed files with 148 additions and 102 deletions
+5 -4
View File
@@ -3,6 +3,7 @@ import http from 'http';
import { CryptoService } from './CryptoService';
import { DatabaseService, type Registry, type RegistryType } from './DatabaseService';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -141,7 +142,7 @@ function httpGet(
nextHeaders = rest;
}
if (isDebugEnabled()) {
console.debug(`[RegistryService][debug] redirect ${status} ${url} -> ${nextUrl.toString()} (auth ${nextHeaders === headers ? 'kept' : 'stripped'})`);
console.debug(`[RegistryService][debug] redirect ${status} ${sanitizeForLog(url)} -> ${sanitizeForLog(nextUrl.toString())} (auth ${nextHeaders === headers ? 'kept' : 'stripped'})`);
}
httpGet(nextUrl.toString(), nextHeaders, timeoutMs, false).then(resolve, reject);
return;
@@ -208,7 +209,7 @@ export class RegistryService {
created_at: now,
updated_at: now,
});
console.info(`[RegistryService] Registry created: id=${id} type=${input.type} name="${input.name}"`);
console.info(`[RegistryService] Registry created: id=${id} type=${sanitizeForLog(input.type)} name="${sanitizeForLog(input.name)}"`);
return id;
}
@@ -282,7 +283,7 @@ export class RegistryService {
return { success: false, error: 'AWS region is required for ECR registries.' };
}
if (isDebugEnabled()) {
console.debug(`[RegistryService][debug] testWithCredentials ECR region=${input.aws_region}`);
console.debug(`[RegistryService][debug] testWithCredentials ECR region=${sanitizeForLog(input.aws_region)}`);
}
await this.fetchEcrToken(input.username, input.secret, input.aws_region);
if (isDebugEnabled()) {
@@ -294,7 +295,7 @@ export class RegistryService {
const probeUrl = toProbeUrl(input.url, input.type);
const basicAuth = Buffer.from(`${input.username}:${input.secret}`).toString('base64');
if (isDebugEnabled()) {
console.debug(`[RegistryService][debug] testWithCredentials probing ${probeUrl}/v2/`);
console.debug(`[RegistryService][debug] testWithCredentials probing ${sanitizeForLog(probeUrl)}/v2/`);
}
const res = await httpGet(`${probeUrl}/v2/`, { Authorization: `Basic ${basicAuth}` });