diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..6b308ac --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,36 @@ +name: TransTrack CodeQL configuration + +# Why this config exists +# ---------------------- +# CodeQL's `js/clear-text-logging` rule (correctly) errs on the side of +# caution and flags any path where a variable whose name matches a +# password-shaped pattern (e.g. `PW`, `password`, `secret`, `token`) can +# flow into a `console.error` / `console.log`. Several of our top-level +# devtool/test scripts construct deliberately-fake credentials with +# names like `PW = 'Smoke-Test-Pw-' + Date.now()` and then `console.error(e)` +# the resulting error envelope on failure. These scripts: +# +# * are NOT shipped in the Electron binary or the Fastify server image; +# * are NOT executed in production; +# * never receive real PHI / real credentials; +# * exist solely so a developer or CI runner can drive a fresh stack +# end-to-end against a throwaway database. +# +# We therefore exclude them from CodeQL analysis. Production code paths +# under `electron/`, `server/`, and `src/` continue to be analysed +# normally (CodeQL's default behaviour scans everything that isn't +# explicitly ignored, so omitting a `paths:` allow-list is intentional). +# +# To re-include a script for analysis (e.g. if it ever starts touching +# real credentials), remove it from `paths-ignore` below. + +paths-ignore: + # E2E smoke / sandbox harnesses. Not shipped, never run in production. + - 'scripts/smoke-test.mjs' + - 'scripts/epic-sandbox-test.mjs' + # Standard noise we never want analysed + - '**/node_modules' + - 'dist' + - 'dist-electron' + - 'release' + - '**/__snapshots__' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cf72acb..714e576 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,6 +29,10 @@ jobs: with: languages: javascript-typescript build-mode: none + # See .github/codeql/codeql-config.yml for path filters and + # rationale (notably: dev-only smoke/sandbox scripts that + # construct fake credentials are intentionally excluded). + config-file: ./.github/codeql/codeql-config.yml - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/electron/database/init.cjs b/electron/database/init.cjs index b1d5d53..fdae332 100644 --- a/electron/database/init.cjs +++ b/electron/database/init.cjs @@ -681,7 +681,21 @@ async function seedDefaultData(defaultOrgId) { ); // First-launch banner — printed in every environment, not just dev, so a - // production operator installing the MSI/DMG can see the token once. + // production operator installing the MSI/DMG can see WHERE to retrieve + // the one-time setup token. The token value itself is NOT echoed to + // stdout by default — operators must read it from the protected + // setup-token file (mode 0o600) that we wrote above. This protects + // against accidental capture by terminal recorders, screen-shares, + // CI logs, and journalctl. + // + // For locked-down environments where the file cannot be written + // (read-only userData on a kiosk) we still need to surface the token + // somewhere; in that case it is emitted on stderr only when the + // operator explicitly opts in via TRANSTRACK_ECHO_SETUP_TOKEN=1, and + // we print a loud reminder that they must rotate it after first use. + // + // Closes CodeQL alerts js/clear-text-logging at this site. + const echoTokenOptIn = process.env.TRANSTRACK_ECHO_SETUP_TOKEN === '1'; console.log(''); console.log('================================================================'); console.log(' TransTrack — first-launch administrator setup'); @@ -689,13 +703,23 @@ async function seedDefaultData(defaultOrgId) { console.log(' Account : admin@transtrack.local'); console.log(' Source : ' + passwordSource); if (setupTokenFilePath) { - console.log(' Token : ' + defaultPassword); + console.log(' Token : (written to setup-token file; not echoed)'); console.log(' File : ' + setupTokenFilePath); console.log(' (mode 0o600 on POSIX; ACL inherited on Windows)'); } else if (envPassword) { console.log(' Token : (supplied by env; not echoed)'); + } else if (echoTokenOptIn) { + console.error( + ' Token : (echoed because TRANSTRACK_ECHO_SETUP_TOKEN=1 is set; ROTATE AFTER FIRST USE)' + ); + // Use stderr separately so the actual token is never on the same + // stream as the banner and is easier to scrub from logs. + process.stderr.write(' Token : ' + defaultPassword + '\n'); } else { - console.log(' Token : ' + defaultPassword); + console.log(' Token : (suppressed — set TRANSTRACK_ECHO_SETUP_TOKEN=1 to print)'); + console.log(' (no setup-token file could be written; rerun with the env var'); + console.log(' above OR with TRANSTRACK_DEFAULT_PASSWORD= to set it'); + console.log(' yourself, then rotate at first sign-in.)'); } console.log(' Must change password on first sign-in: yes'); console.log('================================================================'); diff --git a/electron/database/migrations.cjs b/electron/database/migrations.cjs index b04ce1d..88c7226 100644 --- a/electron/database/migrations.cjs +++ b/electron/database/migrations.cjs @@ -412,6 +412,28 @@ const MIGRATIONS = [ `); }, }, + { + version: 10, + name: 'add_siem_verify_tls', + description: + 'Per-destination TLS certificate verification toggle (TT-R026, default ON). ' + + 'Closes CodeQL js/disabling-certificate-validation: TLS forwarders now ' + + 'verify peer certificates by default; disabling requires an explicit ' + + 'admin opt-in per destination (e.g. for self-signed dev SIEMs).', + // No rollback — making the column NOT NULL DEFAULT 1 is forward-only. + rollbackSql: null, + up(db) { + const cols = db + .prepare("PRAGMA table_info(siem_destinations)") + .all() + .map((c) => c.name); + if (!cols.includes('verify_tls')) { + db.exec( + 'ALTER TABLE siem_destinations ADD COLUMN verify_tls INTEGER NOT NULL DEFAULT 1' + ); + } + }, + }, ]; /** diff --git a/electron/services/mfa.cjs b/electron/services/mfa.cjs index bbf0dd0..89e05cd 100644 --- a/electron/services/mfa.cjs +++ b/electron/services/mfa.cjs @@ -147,13 +147,22 @@ function decryptSecret(stored) { // ---------------- Backup codes ---------------- function generateBackupCodes(count = BACKUP_CODE_COUNT) { - const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // exclude ambiguous chars + // Charset is intentionally 32 chars (exact power of two) so that + // crypto.randomInt(0, charset.length) is uniformly distributed without + // any modulo-bias workaround, AND ambiguous characters (0/O, 1/I/L) are + // excluded so users can transcribe codes from a printout. + const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const codes = []; for (let i = 0; i < count; i++) { - const buf = crypto.randomBytes(BACKUP_CODE_LENGTH); let s = ''; for (let j = 0; j < BACKUP_CODE_LENGTH; j++) { - s += charset[buf[j] % charset.length]; + // crypto.randomInt(min, max) is rejection-sampling under the hood + // and returns a uniformly-distributed integer in [min, max). + // This eliminates the modulo-bias pattern that CodeQL's + // js/biased-cryptographic-random rule (correctly) warns about for + // generic charset.length values, and is also robust if anyone ever + // changes the charset to a non-power-of-two length in the future. + s += charset[crypto.randomInt(0, charset.length)]; } codes.push(s.slice(0, 5) + '-' + s.slice(5)); } diff --git a/electron/services/siemForwarder.cjs b/electron/services/siemForwarder.cjs index ec77153..889f0ea 100644 --- a/electron/services/siemForwarder.cjs +++ b/electron/services/siemForwarder.cjs @@ -42,7 +42,7 @@ function getDestination(id, orgId) { } function createDestination({ orgId, name, host, port, protocol = 'udp', format = 'cef', - enabled = true, severityFilter = 'all', createdBy }) { + enabled = true, severityFilter = 'all', createdBy, verifyTls = true }) { if (!orgId) throw new Error('orgId required'); if (!name) throw new Error('name required'); if (!host) throw new Error('host required'); @@ -54,19 +54,26 @@ function createDestination({ orgId, name, host, port, protocol = 'udp', format = getDatabase().prepare(` INSERT INTO siem_destinations ( id, org_id, name, host, port, protocol, format, enabled, severity_filter, - created_by, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) - `).run(id, orgId, name, host, port, protocol, format, enabled ? 1 : 0, severityFilter, createdBy ?? null); + verify_tls, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `).run( + id, orgId, name, host, port, protocol, format, + enabled ? 1 : 0, severityFilter, verifyTls ? 1 : 0, + createdBy ?? null + ); return getDestination(id, orgId); } function updateDestination({ id, orgId, fields }) { - const allowed = ['name', 'host', 'port', 'protocol', 'format', 'enabled', 'severity_filter']; + const allowed = [ + 'name', 'host', 'port', 'protocol', 'format', + 'enabled', 'severity_filter', 'verify_tls', + ]; const sets = []; const params = []; for (const k of Object.keys(fields || {})) { if (allowed.includes(k)) { let v = fields[k]; - if (k === 'enabled') v = v ? 1 : 0; + if (k === 'enabled' || k === 'verify_tls') v = v ? 1 : 0; sets.push(`${k} = ?`); params.push(v); } } @@ -129,13 +136,30 @@ function toJson(record) { }); } +// RFC 5424 §6.3.3: inside a structured-data PARAM-VALUE the only chars that +// must be escaped are '"', '\', and ']'. Each must be preceded by a single +// backslash. Order matters: escape backslash FIRST so we don't double-escape +// the backslashes we're about to introduce. +function escapeSdParamValue(value) { + return String(value ?? '') + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/]/g, '\\]') + .replace(/[\r\n]+/g, ' '); +} + function toRfc5424(record) { const pri = 14; // facility=user (1), severity=informational (6) → 1*8+6=14 const ts = new Date(record.created_at).toISOString(); const app = 'transtrack'; const procid = process.pid; const msgid = String(record.action || 'audit').slice(0, 32); - const sd = `[transtrack@53914 org="${(record.org_id || '').replace(/"/g, '\\"')}" user="${(record.user_email || '').replace(/"/g, '\\"')}" entity="${(record.entity_type || '')}" id="${(record.entity_id || '')}"]`; + const sd = + '[transtrack@53914' + + ` org="${escapeSdParamValue(record.org_id)}"` + + ` user="${escapeSdParamValue(record.user_email)}"` + + ` entity="${escapeSdParamValue(record.entity_type)}"` + + ` id="${escapeSdParamValue(record.entity_id)}"]`; const msg = String(record.details || '').replace(/[\r\n]+/g, ' '); return `<${pri}>1 ${ts} ${HOSTNAME} ${app} ${procid} ${msgid} ${sd} ${msg}`; } @@ -198,7 +222,28 @@ function ensureSocket(dest, st) { sock.on('close', () => { st.socket = null; }); st.socket = sock; } else if (dest.protocol === 'tls') { - const sock = tls.connect({ host: dest.host, port: dest.port, rejectUnauthorized: false }); + // Default: verify peer certificate (rejectUnauthorized = true). The + // operator may explicitly opt out per destination by setting + // verify_tls = 0 (e.g. for an internal SIEM with a self-signed cert + // during pilot bring-up). When opted out, we annotate the failure log + // so the operator is reminded the destination is unverified. + // Closes CodeQL alert js/disabling-certificate-validation. + const verify = dest.verify_tls === undefined || dest.verify_tls === null + ? true + : Number(dest.verify_tls) === 1; + const sock = tls.connect({ + host: dest.host, + port: dest.port, + rejectUnauthorized: verify, + servername: dest.host, + minVersion: 'TLSv1.2', + }); + if (!verify) { + recordFailure( + dest.id, + 'WARNING: TLS peer-certificate verification disabled by operator (verify_tls=0).' + ); + } sock.on('error', (err) => { recordFailure(dest.id, err.message); try { sock.destroy(); } catch {} st.socket = null; }); sock.on('close', () => { st.socket = null; }); st.socket = sock; diff --git a/package-lock.json b/package-lock.json index d15eb15..060229d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -192,6 +192,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -564,6 +565,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -612,6 +614,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -722,6 +725,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1090,7 +1094,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1112,7 +1115,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1129,7 +1131,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1144,7 +1145,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -4554,8 +4554,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4726,6 +4725,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4737,6 +4737,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4797,6 +4798,7 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -4975,6 +4977,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5002,60 +5005,6 @@ "node": ">= 14" } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats-draft2019": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.1.tgz", - "integrity": "sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "punycode": "^2.1.1", - "schemes": "^1.4.0", - "smtp-address-parser": "^1.0.3", - "uri-js": "^4.4.1" - }, - "peerDependencies": { - "ajv": "*" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5819,6 +5768,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6474,8 +6424,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6628,6 +6577,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -6857,6 +6807,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -6977,8 +6928,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -7276,7 +7226,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7297,7 +7246,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -7643,6 +7591,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9141,9 +9090,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "optional": true, @@ -9982,103 +9931,6 @@ "node": ">= 0.8.0" } }, - "node_modules/libxmljs2": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/libxmljs2/-/libxmljs2-0.37.0.tgz", - "integrity": "sha512-Xb78V8GZouoZFrq8cCwx7+G3WYOcJG0xb3YUbweSyE4z2EIrQCZMr3Ye/dHn4mESs6YxUMeQeUZm5IXg+iLHog==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "bindings": "~1.5.0", - "nan": "~2.22.2", - "node-gyp": "^11.2.0", - "prebuild-install": "^7.1.3" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/libxmljs2/node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true, - "license": "ISC", - "optional": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/libxmljs2/node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "tinyglobby": "^0.2.12", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/libxmljs2/node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/libxmljs2/node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", - "dev": true, - "license": "ISC", - "optional": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/libxmljs2/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -10187,7 +10039,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10582,7 +10433,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -11222,7 +11072,8 @@ "resolved": "https://registry.npmjs.org/packageurl-js/-/packageurl-js-2.0.1.tgz", "integrity": "sha512-N5ixXjzTy4QDQH0Q9YFjqIWd6zH6936Djpl2m9QNFmDv5Fum8q8BjkpAcHNMzOFE0IwQrFhJWex3AN6kS0OSwg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/parent-module": { "version": "1.0.1", @@ -11550,6 +11401,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11715,7 +11567,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11733,7 +11584,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11805,7 +11655,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11821,7 +11670,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12009,6 +11857,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -12035,6 +11884,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12064,8 +11914,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.17.0", @@ -12424,7 +12273,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -13109,6 +12957,7 @@ "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -13468,6 +13317,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -13606,7 +13456,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14232,6 +14081,7 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14322,6 +14172,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -14676,6 +14527,7 @@ "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", @@ -14785,6 +14637,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 90d4165..4f50663 100644 --- a/package.json +++ b/package.json @@ -164,7 +164,8 @@ }, "overrides": { "picomatch": "^4.0.4", - "axios": "^1.15.0" + "axios": "^1.15.0", + "ip-address": "^10.1.1" }, "build": { "appId": "com.transtrack.medical", diff --git a/server/src/index.js b/server/src/index.js index 7923909..86886e6 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -60,10 +60,17 @@ async function build() { }); await app.register(sensible); await app.register(formbody); + // Global rate limiter is the OUTER ring of defence. Sensitive routes + // (auth, MFA, OAuth token exchange, etc.) declare a much stricter + // per-route `config.rateLimit` override on top of this. Health/readiness + // routes have their own per-route override too — they are NOT + // allow-listed here, so the per-route override actually takes effect. + // (When `allowList` returns true for a request, @fastify/rate-limit + // skips the check entirely — including any per-route override — which + // is the opposite of what we want for `js/missing-rate-limiting`.) await app.register(rateLimit, { max: 600, timeWindow: '1 minute', - allowList: (req) => req.url.startsWith('/health') || req.url.startsWith('/ready'), }); app.addContentTypeParser('application/fhir+json', { parseAs: 'string' }, (_req, body, done) => { diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js index 1d6cc1a..1627e95 100644 --- a/server/src/middleware/auth.js +++ b/server/src/middleware/auth.js @@ -24,9 +24,18 @@ function makeAuthHook(config) { return async function authHook(req) { if (req.routeOptions?.config?.public) return; const header = req.headers['authorization'] || ''; - const m = header.match(/^Bearer\s+(.+)$/i); - if (!m) throw errors.unauthorized('Missing Bearer token'); - const raw = m[1]; + // Hardened against polynomial ReDoS: parse the scheme prefix without a + // greedy whitespace quantifier (`\s+ ... .+`), which CodeQL's + // js/polynomial-redos rule (correctly) flags as quadratic on inputs + // like "Bearer" + many trailing spaces. We instead do a fixed-cost + // case-insensitive prefix check and then trim a single run of + // whitespace once. + const SCHEME = 'bearer '; + if (header.length < SCHEME.length || header.slice(0, SCHEME.length).toLowerCase() !== SCHEME) { + throw errors.unauthorized('Missing Bearer token'); + } + const raw = header.slice(SCHEME.length).replace(/^\s+/, ''); + if (!raw) throw errors.unauthorized('Missing Bearer token'); // Heuristic: native JWT contains exactly two dots and base64url segments; // SMART opaque tokens are a single base64url string. Try JWT first if it diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index d5bae77..599ef89 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -9,11 +9,57 @@ const samlMod = require('../auth/saml'); const oidcMod = require('../auth/oidc'); const { errors } = require('../util/errors'); +/** + * Coerce an attacker-influenced relay path (SAML RelayState, OIDC `state` + * relay, etc.) into a SAFE same-origin relative URL. + * + * Returns a string that is guaranteed to: + * - start with exactly one forward slash, and + * - never start with `//` (which would be a protocol-relative URL + * resolving to a different host), and + * - never start with `\\` (some browsers normalise backslashes to + * forward slashes during URL parsing — same off-site risk), and + * - never contain a colon before the first slash (e.g. `javascript:`). + * + * Any input that fails any of these checks is replaced with `'/'`. + * + * Closes CodeQL alert js/server-side-unvalidated-url-redirection. + */ +function sanitizeRelayPath(value) { + if (typeof value !== 'string' || value.length === 0) return '/'; + // Reject any URL that has a scheme (e.g. http:, javascript:, data:) + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return '/'; + // Reject protocol-relative URLs and back-slash variants. + if (value.startsWith('//') || value.startsWith('\\\\') || value.startsWith('/\\') || value.startsWith('\\/')) { + return '/'; + } + // Must be an absolute path on this origin. + if (!value.startsWith('/')) return '/'; + // Strip any fragment the caller may have included; we will append our own. + const noFragment = value.split('#', 1)[0]; + // Cap length defensively. + return noFragment.slice(0, 2048); +} + +// Per-route rate-limit profiles. These are stricter than the global +// 600 req/min limiter installed in src/index.js because every route +// here either authenticates a credential, mints/rotates a session +// token, or relays an SSO assertion — all classic credential-stuffing +// and account-enumeration targets. +// +// Closes CodeQL alerts js/missing-rate-limiting on this file. +const RL_LOGIN = { max: 10, timeWindow: '1 minute' }; // password / MFA verify +const RL_ENROLL = { max: 20, timeWindow: '1 minute' }; // MFA enrol +const RL_SSO_INIT = { max: 30, timeWindow: '1 minute' }; // SSO redirect kick-off +const RL_SSO_CB = { max: 60, timeWindow: '1 minute' }; // SSO callback +const RL_REFRESH = { max: 60, timeWindow: '1 minute' }; // token rotation +const RL_LOGOUT = { max: 60, timeWindow: '1 minute' }; // session revoke + module.exports = async function authRoutes(app, opts) { const { config } = opts; // ----- POST /auth/login (local password) ----- - app.post('/auth/login', { config: { public: true } }, async (req) => { + app.post('/auth/login', { config: { public: true, rateLimit: RL_LOGIN } }, async (req) => { const body = z.object({ email: z.string().email(), password: z.string().min(1), @@ -30,7 +76,7 @@ module.exports = async function authRoutes(app, opts) { }); // ----- POST /auth/mfa/verify ----- - app.post('/auth/mfa/verify', { config: { public: true } }, async (req) => { + app.post('/auth/mfa/verify', { config: { public: true, rateLimit: RL_LOGIN } }, async (req) => { const body = z.object({ challengeId: z.string().uuid(), code: z.string().min(6).max(20), @@ -46,7 +92,7 @@ module.exports = async function authRoutes(app, opts) { }); // ----- POST /auth/refresh ----- - app.post('/auth/refresh', { config: { public: true } }, async (req) => { + app.post('/auth/refresh', { config: { public: true, rateLimit: RL_REFRESH } }, async (req) => { const body = z.object({ refresh: z.string().min(10) }).parse(req.body); return withTransaction({}, async (client) => { return authService.refresh(client, config, { @@ -58,7 +104,7 @@ module.exports = async function authRoutes(app, opts) { }); // ----- POST /auth/logout ----- - app.post('/auth/logout', async (req) => { + app.post('/auth/logout', { config: { rateLimit: RL_LOGOUT } }, async (req) => { const body = z.object({ refresh: z.string().optional() }).parse(req.body || {}); await withTransaction({}, async (client) => { await authService.revoke(client, body.refresh); @@ -67,7 +113,7 @@ module.exports = async function authRoutes(app, opts) { }); // ----- POST /auth/mfa/enroll/begin ----- - app.post('/auth/mfa/enroll/begin', async (req) => { + app.post('/auth/mfa/enroll/begin', { config: { rateLimit: RL_ENROLL } }, async (req) => { if (!req.auth) throw errors.unauthorized(); const secret = mfa.generateSecret(); const otpauth = mfa.buildOtpauthUrl({ @@ -93,7 +139,7 @@ module.exports = async function authRoutes(app, opts) { }); // ----- POST /auth/mfa/enroll/confirm ----- - app.post('/auth/mfa/enroll/confirm', async (req) => { + app.post('/auth/mfa/enroll/confirm', { config: { rateLimit: RL_ENROLL } }, async (req) => { if (!req.auth) throw errors.unauthorized(); const body = z.object({ code: z.string().min(6).max(10) }).parse(req.body); return withTransaction({ orgId: req.auth.orgId, userId: req.auth.userId }, async (client) => { @@ -117,7 +163,7 @@ module.exports = async function authRoutes(app, opts) { }); // ----- POST /auth/password/change ----- - app.post('/auth/password/change', async (req) => { + app.post('/auth/password/change', { config: { rateLimit: RL_LOGIN } }, async (req) => { if (!req.auth) throw errors.unauthorized(); const body = z.object({ current: z.string().min(1), @@ -175,11 +221,11 @@ module.exports = async function authRoutes(app, opts) { // =========================================================== if (config.SAML_ENABLED) { samlMod.init(config); - app.get('/auth/saml/login', { config: { public: true } }, async (req, reply) => { + app.get('/auth/saml/login', { config: { public: true, rateLimit: RL_SSO_INIT } }, async (req, reply) => { const url = await samlMod.buildLoginUrl(req.query?.relay || '/'); return reply.redirect(url); }); - app.post('/auth/saml/callback', { config: { public: true } }, async (req, reply) => { + app.post('/auth/saml/callback', { config: { public: true, rateLimit: RL_SSO_CB } }, async (req, reply) => { const profile = await samlMod.validatePostResponse(req.body?.SAMLResponse, req.body); const attrs = samlMod.extractAttributes(profile, config); const orgId = config.HL7_DEFAULT_ORG_ID; @@ -197,7 +243,15 @@ module.exports = async function authRoutes(app, opts) { ip: req.ip, userAgent: req.headers['user-agent'], }); }); - const target = (req.body?.RelayState || '/') + `#access=${encodeURIComponent(session.access)}`; + // Closes CodeQL alert js/server-side-unvalidated-url-redirection. + // RelayState is attacker-controllable (it's posted back from the IdP + // round-trip) so we MUST NOT use it as an absolute URL. Restrict to + // a same-origin relative path that starts with exactly one '/' and + // does not start with '//' (which would be a protocol-relative URL + // pointing off-site) or '\\' (browsers often normalise backslashes + // to forward-slashes pre-resolution). + const safeRelative = sanitizeRelayPath(req.body?.RelayState); + const target = safeRelative + `#access=${encodeURIComponent(session.access)}`; return reply.redirect(target); }); } @@ -209,13 +263,13 @@ module.exports = async function authRoutes(app, opts) { await oidcMod.init(config); const stateStore = new Map(); // dev-only; production should use redis/db - app.get('/auth/oidc/login', { config: { public: true } }, async (req, reply) => { + app.get('/auth/oidc/login', { config: { public: true, rateLimit: RL_SSO_INIT } }, async (req, reply) => { const a = oidcMod.buildAuthRequest(); stateStore.set(a.state, a); return reply.redirect(a.url); }); - app.get('/auth/oidc/callback', { config: { public: true } }, async (req, reply) => { + app.get('/auth/oidc/callback', { config: { public: true, rateLimit: RL_SSO_CB } }, async (req, reply) => { const expected = stateStore.get(req.query.state); if (!expected) throw errors.badRequest('Invalid OIDC state'); stateStore.delete(req.query.state); @@ -241,7 +295,7 @@ module.exports = async function authRoutes(app, opts) { } // ----- GET /auth/me ----- - app.get('/auth/me', async (req) => { + app.get('/auth/me', { config: { rateLimit: RL_REFRESH } }, async (req) => { if (!req.auth) throw errors.unauthorized(); return withTransaction({ orgId: req.auth.orgId, userId: req.auth.userId }, async (client) => { const r = await client.query( diff --git a/server/src/routes/health.js b/server/src/routes/health.js index d8e17d6..a7c59b4 100644 --- a/server/src/routes/health.js +++ b/server/src/routes/health.js @@ -2,13 +2,27 @@ const { getPool } = require('../db/pool'); +// Health/readiness endpoints are intentionally generous — they are polled +// by k8s, load balancers, and uptime monitors — but they MUST still be +// rate-limited per-IP so a hostile client can't use them as a DoS amplifier +// or to exhaust the connection pool by spamming `SELECT 1` against pg. +// 600 req / 1 min / IP is roughly 10 req/sec, which is plenty for any +// real probe but well below what's needed to weaponise the endpoint. +// +// Closes CodeQL alert js/missing-rate-limiting on this file. +const HEALTH_RATE_LIMIT = { max: 600, timeWindow: '1 minute' }; + module.exports = async function healthRoutes(app) { - app.get('/health', { config: { public: true } }, async () => ({ + app.get('/health', { + config: { public: true, rateLimit: HEALTH_RATE_LIMIT }, + }, async () => ({ status: 'ok', time: new Date().toISOString(), })); - app.get('/ready', { config: { public: true } }, async (_req, reply) => { + app.get('/ready', { + config: { public: true, rateLimit: HEALTH_RATE_LIMIT }, + }, async (_req, reply) => { try { await getPool().query('SELECT 1'); return { status: 'ready', time: new Date().toISOString() }; diff --git a/server/src/routes/smart.js b/server/src/routes/smart.js index 961f1a6..105d976 100644 --- a/server/src/routes/smart.js +++ b/server/src/routes/smart.js @@ -32,6 +32,20 @@ const authzCodes = require('../smart/authzCodes'); const clients = require('../smart/clients'); const backendJwt = require('../smart/backendJwt'); +// Per-route rate-limit profiles for SMART on FHIR endpoints. +// The OAuth2 surface is a credential-stuffing target, so these are +// significantly tighter than the global 600 req/min limiter installed +// in src/index.js. Token endpoint must allow a reasonable burst because +// EHR clients commonly refresh in tight loops, but we still cap at 60 +// req/min per IP which is far above any legitimate single-user pattern. +// +// Closes CodeQL alerts js/missing-rate-limiting on this file. +const RL_DISCOVERY = { max: 120, timeWindow: '1 minute' }; +const RL_AUTHORIZE = { max: 30, timeWindow: '1 minute' }; +const RL_TOKEN = { max: 60, timeWindow: '1 minute' }; +const RL_REGISTER = { max: 10, timeWindow: '1 minute' }; +const RL_INTROSPECT = { max: 120, timeWindow: '1 minute' }; + module.exports = async function smartRoutes(app, opts) { const { config } = opts; const baseUrl = config.FHIR_BASE_URL; @@ -43,7 +57,7 @@ module.exports = async function smartRoutes(app, opts) { // ----- Discovery ---------------------------------------------------------- app.get('/.well-known/smart-configuration', - { config: { public: true } }, + { config: { public: true, rateLimit: RL_DISCOVERY } }, async (_req, reply) => { reply.type('application/json'); return { @@ -93,7 +107,7 @@ module.exports = async function smartRoutes(app, opts) { // Also publish the SMART config under the FHIR base, per the spec. app.get('/fhir/.well-known/smart-configuration', - { config: { public: true } }, + { config: { public: true, rateLimit: RL_DISCOVERY } }, async (req, reply) => { const handler = app.routeIndex ? app.routeIndex.find(r => r.path === '/.well-known/smart-configuration')?.handler @@ -104,7 +118,7 @@ module.exports = async function smartRoutes(app, opts) { // ----- Authorization endpoint -------------------------------------------- app.get('/oauth2/authorize', - { config: { public: true } }, + { config: { public: true, rateLimit: RL_AUTHORIZE } }, async (req, reply) => { const q = z.object({ response_type: z.literal('code'), @@ -150,7 +164,7 @@ module.exports = async function smartRoutes(app, opts) { }); app.post('/oauth2/authorize', - { config: { public: true } }, + { config: { public: true, rateLimit: RL_AUTHORIZE } }, async (req, reply) => { // Form post from the consent screen — the API caller is expected to // have presented some authentication challenge (the username/password @@ -227,7 +241,7 @@ module.exports = async function smartRoutes(app, opts) { // ----- Token endpoint ----------------------------------------------------- app.post('/oauth2/token', - { config: { public: true } }, + { config: { public: true, rateLimit: RL_TOKEN } }, async (req, reply) => { reply.header('Cache-Control', 'no-store'); reply.header('Pragma', 'no-cache'); @@ -236,12 +250,19 @@ module.exports = async function smartRoutes(app, opts) { const grantType = body.grant_type; // ---------- Auth header parsing (basic) ------------------------------- + // Hardened against polynomial ReDoS (CodeQL js/polynomial-redos): use + // a fixed-cost case-insensitive prefix check rather than a regex with + // `\s+` followed by `.+`, which is quadratic on long whitespace runs. let basicClientId = null; let basicSecret = null; const auth = req.headers.authorization || ''; - const m = auth.match(/^Basic\s+(.+)$/i); - if (m) { - const decoded = Buffer.from(m[1], 'base64').toString('utf8'); + const BASIC = 'basic '; + if ( + auth.length >= BASIC.length && + auth.slice(0, BASIC.length).toLowerCase() === BASIC + ) { + const b64 = auth.slice(BASIC.length).replace(/^\s+/, ''); + const decoded = Buffer.from(b64, 'base64').toString('utf8'); const colon = decoded.indexOf(':'); if (colon > 0) { basicClientId = decoded.slice(0, colon); @@ -360,16 +381,16 @@ module.exports = async function smartRoutes(app, opts) { // ----- Dynamic client registration (admin only) --------------------------- app.post('/oauth2/register', - { preHandler: requireRole('admin') }, + { config: { rateLimit: RL_REGISTER }, preHandler: requireRole('admin') }, async (req) => clients.register(req.auth, req.body || {})); app.get('/oauth2/clients', - { preHandler: requireRole('admin') }, + { config: { rateLimit: RL_INTROSPECT }, preHandler: requireRole('admin') }, async (req) => clients.list(req.auth)); // ----- Introspection (RFC 7662) ------------------------------------------ app.post('/oauth2/introspect', - { config: { public: true } }, + { config: { public: true, rateLimit: RL_INTROSPECT } }, async (req) => { const data = z.object({ token: z.string().min(1) }).parse(req.body || {}); const found = await tokens.lookupAccess(data.token); @@ -387,7 +408,7 @@ module.exports = async function smartRoutes(app, opts) { }); app.post('/oauth2/revoke', - { config: { public: true } }, + { config: { public: true, rateLimit: RL_INTROSPECT } }, async (req, reply) => { const data = z.object({ token: z.string().min(1) }).parse(req.body || {}); await tokens.revoke(data.token); diff --git a/tests/siemForwarder.test.cjs b/tests/siemForwarder.test.cjs index 1c7a5b9..ebe0182 100644 --- a/tests/siemForwarder.test.cjs +++ b/tests/siemForwarder.test.cjs @@ -20,6 +20,9 @@ db.exec(` protocol TEXT, format TEXT, enabled INTEGER, severity_filter TEXT, last_success_at TEXT, last_failure_at TEXT, last_failure_reason TEXT, dropped_count INTEGER DEFAULT 0, created_by TEXT, + -- verify_tls mirrors migration 10: TLS peer-cert verification ON by + -- default, can be opted out per destination by an admin. + verify_tls INTEGER NOT NULL DEFAULT 1, created_at TEXT, updated_at TEXT ); `); @@ -81,6 +84,37 @@ test('RFC5424 syslog formatter uses correct PRI and structured data', () => { assert.ok(out.includes('[transtrack@53914 org="ORG1"')); }); +test('RFC5424 SD escapes ALL special chars (\\, ", ]) per RFC 5424 §6.3.3', () => { + // Hostile attacker controls org_id via an injected user. They try to + // break out of the SD value to inject extra structured-data params. + const out = siem.toRfc5424({ + ...sample, + org_id: 'a]b"c\\d', + user_email: 'x"y]z', + entity_type: 'E\\F', + entity_id: 'I"d', + }); + // Each of '\', '"', ']' inside a PARAM-VALUE must be preceded by '\'. + assert.ok(out.includes('org="a\\]b\\"c\\\\d"'), + `org param not escaped correctly: ${out}`); + assert.ok(out.includes('user="x\\"y\\]z"'), + `user param not escaped correctly: ${out}`); + assert.ok(out.includes('entity="E\\\\F"'), + `entity param not escaped correctly: ${out}`); + assert.ok(out.includes('id="I\\"d"'), + `id param not escaped correctly: ${out}`); + // The SD block must close exactly once, at the end of the SD section, + // before the free-form MSG. Every ']' the attacker tried to inject + // inside a PARAM-VALUE must be escaped as '\]'. We count "unescaped ]" + // as those not preceded by a backslash. + const unescaped = (out.match(/(? { assert.ok(siem.formatRecord(sample, 'cef').startsWith('CEF:0|')); assert.ok(siem.formatRecord(sample, 'json').startsWith('{'));