Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/quiet-lions-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'seamless-auth-api': patch
---

Stop untrusted values reaching the log and the audit trail intact.

Log messages interpolate request paths, provider ids and similar caller-supplied
values through template strings across the codebase. A newline in one of those
let a caller forge a second log entry. Control characters are now escaped
centrally in the logger format, the single place every line already passes
through for redaction, rather than at each call site where one missed
interpolation reopens it.

`redactSensitiveValue` built its output on a plain object, so a `__proto__` key
in audit metadata hit the prototype setter instead of creating a property: the
key vanished from the redacted output unredacted, and replaced that object's
prototype with caller-supplied content. The output is now built on a null
prototype, so the key is recorded as ordinary data.

Dev signing key generation checked for a key file and then wrote one, so two
processes starting together could both generate and both write, leaving one
signing with a key that was neither on disk nor published in JWKS. Both paths
now create the file exclusively and adopt the winner's key on losing the race.

The slug trim matches a single leading or trailing dash rather than a run. The
preceding collapse leaves no two dashes adjacent, so a run cannot occur, and
matching one made the trim backtrack over an input of many dashes for a
repetition that was never there.
29 changes: 29 additions & 0 deletions .changeset/tall-jokes-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'seamless-auth-api': patch
---

Stop the test suite failing on assertions unrelated to the change under test.

`vi.clearAllMocks()` in each spec's `beforeEach` empties call history but leaves
the `mockResolvedValueOnce` queue intact, so a value queued by one test and never
consumed was returned to a later, unrelated one. That shifted every subsequent
queued value by a place, surfacing as a wrong status, a wrong body, or a request
that never settled and timed out. Vitest now resets mocks between tests, which is
what drains the queue.

`vi.stubEnv` and `vi.stubGlobal` write to the process rather than the module
registry, and `isolate` does not roll those back between files, so a stubbed
`NODE_ENV` or a stubbed global `fetch` outlived the file that set it. Both are
now restored automatically. `APP_ORIGINS` moved from a stub in `mocks.ts` to a
plain assignment in `env.ts`, since restoring stubs before every test would
otherwise drop it after the first test of each file.

Route handlers answer the request before their fire-and-forget audit logging
settles, so supertest resolved with continuations still queued and a stray call
could land in the middle of the next test, breaking a `toHaveBeenCalledTimes` or
a `toHaveBeenNthCalledWith` on a shared mock. Those are now drained after every
test.

With the leaks closed, spec files no longer have to run one at a time:
`fileParallelism` is back on and the suite runs in about a sixth of the time.
`npm run coverage` no longer forces sequential execution either.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage --fileParallelism=false",
"coverage": "vitest run --coverage",
"coverage:badge": "node ./src/scripts/updateCoverageBadge.mjs",
"test:ci": "CI=true vitest",
"test:e2e": "CI=false vitest",
Expand Down
14 changes: 7 additions & 7 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 27 additions & 7 deletions src/scripts/keyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
*/

import crypto from 'crypto';
import * as fs from 'fs';
import { mkdir, writeFile } from 'fs/promises';
import { access, mkdir, writeFile } from 'fs/promises';
import path from 'path';

import getLogger from '../utils/logger.js';
Expand All @@ -19,13 +18,22 @@ const localKeyDir = path.resolve('./keys');
const localPrivate = path.join(localKeyDir, 'private.pem');
const localPublic = path.join(localKeyDir, 'public.pem');

// A fast path only: the exclusive write below is what actually settles a race, so a
// stale answer here costs a wasted keypair, never a clobbered one.
async function pathExists(target: string) {
try {
await access(target);
return true;
} catch {
return false;
}
}

// Create local keys if needed
async function ensureLocalDevKeys() {
if (!fs.existsSync(localKeyDir)) {
await mkdir(localKeyDir, { recursive: true });
}
await mkdir(localKeyDir, { recursive: true });

if (fs.existsSync(localPrivate) && fs.existsSync(localPublic)) {
if (await pathExists(localPrivate)) {
logger.info('Dev keys already exist.');
return;
}
Expand All @@ -37,7 +45,19 @@ async function ensureLocalDevKeys() {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

await writeFile(localPrivate, privateKey);
try {
// Exclusive create rather than exists-then-write: the pair must come from one
// generation, and a concurrent run must not replace a private key whose public
// half has already been published.
await writeFile(localPrivate, privateKey, { flag: 'wx' });
} catch (error) {
if ((error as { code?: string }).code === 'EEXIST') {
logger.info('Dev keys already exist.');
return;
}
throw error;
}

await writeFile(localPublic, publicKey);
logger.info('Dev keypair created in ./keys/');
}
Expand Down
17 changes: 11 additions & 6 deletions src/services/organizationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,17 @@ export interface SerializedOrganization {
}

function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
// Single dash rather than `-+`: the collapse above leaves no two adjacent, so one is
// all there can be, and matching a run would backtrack over an input of many dashes
// for a repetition that cannot occur.
.replace(/^-|-$/g, '')
.slice(0, 80)
);
}

export function normalizeOrganizationSlug(name: string, slug?: string | null) {
Expand Down
9 changes: 4 additions & 5 deletions src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import fs from 'fs';
import path from 'path';
import { createLogger, format, Logger, transports } from 'winston';

import { redactSensitiveText } from './redaction.js';
import { escapeLogControlCharacters, redactSensitiveText } from './redaction.js';

const { combine, timestamp, printf } = format;

Expand All @@ -25,10 +25,9 @@ export default function getLogger(moduleName: string): Logger {
const isProd = process.env.NODE_ENV === 'production';

const logFormat = printf(({ level, message, timestamp }) => {
const renderedMessage =
typeof message === 'string'
? redactSensitiveText(message)
: redactSensitiveText(String(message));
const renderedMessage = escapeLogControlCharacters(
redactSensitiveText(typeof message === 'string' ? message : String(message)),
);
return `${timestamp} [${moduleName}] ${level.toUpperCase()} - ${renderedMessage}`;
});

Expand Down
22 changes: 21 additions & 1 deletion src/utils/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ export function redactSensitiveText(value: string) {
);
}

/**
* A log entry is one line, so a newline in an interpolated value lets a caller forge a
* second entry. Request paths, provider ids and the like reach log messages through
* template strings across the codebase, so this runs centrally in the logger format
* rather than at each call site, where one missed interpolation reopens the hole.
*/
export function escapeLogControlCharacters(value: string) {
// eslint-disable-next-line no-control-regex
return value.replace(/[\u0000-\u001f\u007f]/g, (character) => {
if (character === '\n') return '\\n';
if (character === '\r') return '\\r';
if (character === '\t') return '\\t';
return `\\x${character.charCodeAt(0).toString(16).padStart(2, '0')}`;
});
}

export function redactSensitiveValue(value: unknown, depth = 0): unknown {
if (value === null || value === undefined) {
return value;
Expand All @@ -64,7 +80,11 @@ export function redactSensitiveValue(value: unknown, depth = 0): unknown {
return value.slice(0, MAX_ARRAY_ITEMS).map((item) => redactSensitiveValue(item, depth + 1));
}

const redacted: Record<string, unknown> = {};
// Null prototype because the keys come from untrusted audit metadata. On a normal
// object a `__proto__` key hits the setter instead of creating an own property, so
// it vanishes from the output unredacted and swaps the prototype for whatever the
// caller sent.
const redacted = Object.create(null) as Record<string, unknown>;

for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
redacted[key] = isSensitiveKey(key) ? REDACTED : redactSensitiveValue(nestedValue, depth + 1);
Expand Down
34 changes: 28 additions & 6 deletions src/utils/signingKeyStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,23 @@ const devKeyDir = path.resolve('./keys/dev');
const devPrivateKeyPath = path.join(devKeyDir, 'private.pem');
const devKid = 'dev-main';

function ensureDevKeys() {
if (!fs.existsSync(devKeyDir)) {
fs.mkdirSync(devKeyDir, { recursive: true });
function readDevPrivateKey() {
try {
return fs.readFileSync(devPrivateKeyPath, 'utf8');
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') {
return null;
}
throw error;
}
}

if (fs.existsSync(devPrivateKeyPath)) {
return fs.readFileSync(devPrivateKeyPath, 'utf8');
function ensureDevKeys() {
fs.mkdirSync(devKeyDir, { recursive: true });

const existing = readDevPrivateKey();
if (existing) {
return existing;
}

// Generate a local RSA keypair in dev
Expand All @@ -53,7 +63,19 @@ function ensureDevKeys() {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

fs.writeFileSync(devPrivateKeyPath, privateKey, 'utf8');
try {
// Exclusive create: two dev processes starting together would otherwise both
// generate and both write, leaving one of them signing with a key that is not
// the one on disk and not the one JWKS publishes. Losing the race means
// adopting the winner's key, not overwriting it.
fs.writeFileSync(devPrivateKeyPath, privateKey, { encoding: 'utf8', flag: 'wx' });
} catch (error) {
if ((error as { code?: string }).code === 'EEXIST') {
return fs.readFileSync(devPrivateKeyPath, 'utf8');
}
throw error;
}

fs.writeFileSync(path.join(devKeyDir, 'public.pem'), publicKey, 'utf8');

logger.info('Generated dev RSA keypair at ./keys/dev/');
Expand Down
8 changes: 2 additions & 6 deletions tests/integration/user/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,9 @@ describe('GET /users/me', () => {
});

it('returns 404 when no user', async () => {
// override auth middleware behavior indirectly by mocking credential call
const { attachAuthMiddleware } = await import('../../../src/middleware/attachAuthMiddleware');

// hack: simulate no user
const res = await request(app).get('/users/me');
const res = await request(app).get('/users/me').set('x-omit-user', 'true');

expect([200, 404]).toContain(res.status);
expect(res.status).toBe(404);
});

it('handles error path', async () => {
Expand Down
4 changes: 3 additions & 1 deletion tests/setup/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
process.env.NODE_ENV = 'test';
process.env.APP_ORIGINS = 'http://localhost:5174';
// Set here rather than stubbed in mocks.ts: unstubEnvs reverts stubs before every test,
// which would drop this after the first test of each file.
process.env.APP_ORIGINS = 'http://localhost:5137';

// Default: use mock DB mode
process.env.TEST_DB = process.env.TEST_DB || 'mock';
Expand Down
15 changes: 11 additions & 4 deletions tests/setup/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { vi } from 'vitest';

vi.stubEnv('APP_ORIGINS', 'http://localhost:5137');
import { afterEach, vi } from 'vitest';

// Handlers answer the request before their fire-and-forget audit logging settles, so
// supertest resolves with continuations still queued. Draining them here keeps those
// calls inside the test that caused them: otherwise one lands mid-way through the next
// test and breaks a toHaveBeenCalledTimes or a toHaveBeenNthCalledWith on a shared mock.
// A single turn is enough because the mocks resolve without real I/O.
afterEach(async () => {
await new Promise((resolve) => setImmediate(resolve));
});

export let mockUser: any = {
id: 'user-1',
Expand Down Expand Up @@ -78,7 +85,7 @@ vi.mock('../../src/models/organizations.js', () => ({
vi.mock('../../src/models/organizationMemberships.js', () => ({
OrganizationMembership: {
create: vi.fn(),
findAll: vi.fn().mockResolvedValue([]),
findAll: vi.fn(async () => []),
findOne: vi.fn(),
count: vi.fn(),
},
Expand Down
Loading
Loading