fix(security): harden auth reset, rate limiter fallback, and CI secrets

- Move CI_AUTH_SECRET from plaintext to ${{ secrets.CI_AUTH_SECRET }}
- Wrap password reset (update + session kill + token mark) in $transaction
  to prevent stale sessions on partial failure (CWE-613)
- Rate limiter Redis fallback now uses stricter degraded limits
  (maxRequests/10) and logs at error level instead of warn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:03:42 +02:00
parent 98c2554570
commit 110e4ff1aa
5 changed files with 78 additions and 53 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ env:
NODE_VERSION: "20" NODE_VERSION: "20"
PNPM_VERSION: "9.14.2" PNPM_VERSION: "9.14.2"
CI_AUTH_URL: http://localhost:3100 CI_AUTH_URL: http://localhost:3100
CI_AUTH_SECRET: capakraken-ci-build-secret-rotate-if-shared CI_AUTH_SECRET: ${{ secrets.CI_AUTH_SECRET }}
jobs: jobs:
guardrails: guardrails:
@@ -26,7 +26,7 @@ function makeDb(
resetToken?: Partial<Record<string, unknown>>; resetToken?: Partial<Record<string, unknown>>;
} = {}, } = {},
) { ) {
return { const db = {
user: { user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", email: "user@example.com" }), findUnique: vi.fn().mockResolvedValue({ id: "user_1", email: "user@example.com" }),
update: vi.fn().mockResolvedValue({ id: "user_1" }), update: vi.fn().mockResolvedValue({ id: "user_1" }),
@@ -42,7 +42,9 @@ function makeDb(
update: vi.fn().mockResolvedValue({}), update: vi.fn().mockResolvedValue({}),
...overrides.resetToken, ...overrides.resetToken,
}, },
$transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(db)),
} as never; } as never;
return db;
} }
function makeCtx(db = makeDb()) { function makeCtx(db = makeDb()) {
+18 -14
View File
@@ -47,18 +47,20 @@ describe("rate limiter", () => {
vi.doMock("ioredis", () => ({ vi.doMock("ioredis", () => ({
Redis: vi.fn().mockImplementation(() => ({ Redis: vi.fn().mockImplementation(() => ({
on: vi.fn(), on: vi.fn(),
eval: vi.fn(async (_script: string, _numKeys: number, key: string, windowMsValue: string) => { eval: vi.fn(
const now = Date.now(); async (_script: string, _numKeys: number, key: string, windowMsValue: string) => {
const windowMs = Number(windowMsValue); const now = Date.now();
const existing = store.get(key); const windowMs = Number(windowMsValue);
if (!existing || existing.resetAt <= now) { const existing = store.get(key);
store.set(key, { count: 1, resetAt: now + windowMs }); if (!existing || existing.resetAt <= now) {
} else { store.set(key, { count: 1, resetAt: now + windowMs });
existing.count += 1; } else {
} existing.count += 1;
const current = store.get(key)!; }
return [current.count, current.resetAt - now]; const current = store.get(key)!;
}), return [current.count, current.resetAt - now];
},
),
scan: vi.fn(async () => ["0", [...store.keys()]]), scan: vi.fn(async () => ["0", [...store.keys()]]),
del: delMock, del: delMock,
})), })),
@@ -88,7 +90,7 @@ describe("rate limiter", () => {
expect(afterReset.remaining).toBe(1); expect(afterReset.remaining).toBe(1);
}); });
it("falls back to in-memory counters when Redis is unavailable", async () => { it("falls back to degraded in-memory counters when Redis is unavailable", async () => {
vi.doMock("ioredis", () => ({ vi.doMock("ioredis", () => ({
Redis: vi.fn().mockImplementation(() => ({ Redis: vi.fn().mockImplementation(() => ({
on: vi.fn(), on: vi.fn(),
@@ -101,7 +103,9 @@ describe("rate limiter", () => {
})); }));
const { createRateLimiter } = await import("../middleware/rate-limit.js"); const { createRateLimiter } = await import("../middleware/rate-limit.js");
const limiter = createRateLimiter(60_000, 2, { // Degraded fallback uses max(1, floor(maxRequests/10)), so with
// maxRequests=20 the degraded limit is 2.
const limiter = createRateLimiter(60_000, 20, {
backend: "redis", backend: "redis",
redisUrl: "redis://test", redisUrl: "redis://test",
name: "redis-fallback-test", name: "redis-fallback-test",
+38 -25
View File
@@ -37,13 +37,19 @@ const DEFAULT_REDIS_URL = process.env["REDIS_URL"]?.trim();
const warnedRedisFailures = new Set<string>(); const warnedRedisFailures = new Set<string>();
let sharedRedisClient: Redis | null = null; let sharedRedisClient: Redis | null = null;
function getBackendMode( function getBackendMode(requestedBackend: RateLimitBackendMode | undefined): RateLimitBackendMode {
requestedBackend: RateLimitBackendMode | undefined, if (
): RateLimitBackendMode { requestedBackend === "memory" ||
if (requestedBackend === "memory" || requestedBackend === "redis" || requestedBackend === "auto") { requestedBackend === "redis" ||
requestedBackend === "auto"
) {
return requestedBackend; return requestedBackend;
} }
if (DEFAULT_REDIS_BACKEND === "memory" || DEFAULT_REDIS_BACKEND === "redis" || DEFAULT_REDIS_BACKEND === "auto") { if (
DEFAULT_REDIS_BACKEND === "memory" ||
DEFAULT_REDIS_BACKEND === "redis" ||
DEFAULT_REDIS_BACKEND === "auto"
) {
return DEFAULT_REDIS_BACKEND; return DEFAULT_REDIS_BACKEND;
} }
return "auto"; return "auto";
@@ -69,10 +75,7 @@ function getRedisClient(redisUrl: string): Redis {
return sharedRedisClient; return sharedRedisClient;
} }
function createMemoryBackend( function createMemoryBackend(windowMs: number, maxRequests: number): RateLimiterBackend {
windowMs: number,
maxRequests: number,
): RateLimiterBackend {
const store = new Map<string, RateLimitEntry>(); const store = new Map<string, RateLimitEntry>();
const cleanupInterval = setInterval(() => { const cleanupInterval = setInterval(() => {
const now = Date.now(); const now = Date.now();
@@ -127,7 +130,7 @@ function createRedisBackend(
async function runRedisCheck(key: string): Promise<RateLimitResult> { async function runRedisCheck(key: string): Promise<RateLimitResult> {
const client = getRedisClient(options.redisUrl); const client = getRedisClient(options.redisUrl);
const redisKey = `${redisKeyPrefix}:${key}`; const redisKey = `${redisKeyPrefix}:${key}`;
const result = await client.eval( const result = (await client.eval(
` `
local current = redis.call("INCR", KEYS[1]) local current = redis.call("INCR", KEYS[1])
local ttl = redis.call("PTTL", KEYS[1]) local ttl = redis.call("PTTL", KEYS[1])
@@ -140,7 +143,7 @@ function createRedisBackend(
1, 1,
redisKey, redisKey,
String(windowMs), String(windowMs),
) as [number | string, number | string]; )) as [number | string, number | string];
const count = Number(result[0]); const count = Number(result[0]);
const ttlMs = Math.max(0, Number(result[1])); const ttlMs = Math.max(0, Number(result[1]));
@@ -156,13 +159,7 @@ function createRedisBackend(
const matchPattern = `${redisKeyPrefix}:*`; const matchPattern = `${redisKeyPrefix}:*`;
let cursor = "0"; let cursor = "0";
do { do {
const [nextCursor, keys] = await client.scan( const [nextCursor, keys] = await client.scan(cursor, "MATCH", matchPattern, "COUNT", 100);
cursor,
"MATCH",
matchPattern,
"COUNT",
100,
);
cursor = nextCursor; cursor = nextCursor;
if (keys.length > 0) { if (keys.length > 0) {
await client.del(...keys); await client.del(...keys);
@@ -177,9 +174,9 @@ function createRedisBackend(
} catch (error) { } catch (error) {
if (!warnedRedisFailures.has(warningKey)) { if (!warnedRedisFailures.has(warningKey)) {
warnedRedisFailures.add(warningKey); warnedRedisFailures.add(warningKey);
logger.warn( logger.error(
{ err: error, redisUrl: options.redisUrl, limiter: options.name }, { err: error, redisUrl: options.redisUrl, limiter: options.name },
"Rate limiter Redis backend unavailable, falling back to in-memory counters", "Rate limiter Redis backend unavailable, falling back to degraded in-memory counters",
); );
} }
throw error; throw error;
@@ -208,9 +205,19 @@ export function createRateLimiter(
const redisUrl = options.redisUrl?.trim() || DEFAULT_REDIS_URL; const redisUrl = options.redisUrl?.trim() || DEFAULT_REDIS_URL;
const keyPrefix = options.keyPrefix ?? DEFAULT_REDIS_KEY_PREFIX; const keyPrefix = options.keyPrefix ?? DEFAULT_REDIS_KEY_PREFIX;
const shouldUseRedis = backendMode === "redis" || (backendMode === "auto" && Boolean(redisUrl)); const shouldUseRedis = backendMode === "redis" || (backendMode === "auto" && Boolean(redisUrl));
const redisBackend = shouldUseRedis && redisUrl const redisBackend =
? createRedisBackend(windowMs, maxRequests, { name, redisUrl, keyPrefix }) shouldUseRedis && redisUrl
: null; ? createRedisBackend(windowMs, maxRequests, { name, redisUrl, keyPrefix })
: null;
// When Redis is unavailable, apply a stricter limit to compensate for
// per-node isolation (each process keeps independent in-memory counters,
// so the effective cluster-wide limit is maxRequests × nodeCount).
const degradedMemoryBackend = createMemoryBackend(
windowMs,
Math.max(1, Math.floor(maxRequests / 10)),
);
let redisDegraded = false;
const check = (async (key: string) => { const check = (async (key: string) => {
const normalizedKey = key.trim().toLowerCase(); const normalizedKey = key.trim().toLowerCase();
@@ -227,9 +234,15 @@ export function createRateLimiter(
} }
try { try {
return await redisBackend.check(normalizedKey); const result = await redisBackend.check(normalizedKey);
if (redisDegraded) {
redisDegraded = false;
logger.info({ limiter: name }, "Rate limiter Redis backend recovered");
}
return result;
} catch { } catch {
return memoryBackend.check(normalizedKey); redisDegraded = true;
return degradedMemoryBackend.check(normalizedKey);
} }
}) as RateLimiter; }) as RateLimiter;
+18 -12
View File
@@ -94,7 +94,10 @@ export const authRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND", message: "Reset link not found." }); throw new TRPCError({ code: "NOT_FOUND", message: "Reset link not found." });
} }
if (record.usedAt) { if (record.usedAt) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has already been used." }); throw new TRPCError({
code: "BAD_REQUEST",
message: "This reset link has already been used.",
});
} }
if (record.expiresAt < new Date()) { if (record.expiresAt < new Date()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." }); throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." });
@@ -103,19 +106,22 @@ export const authRouter = createTRPCRouter({
const { hash } = await import("@node-rs/argon2"); const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password); const passwordHash = await hash(input.password);
const updatedUser = await ctx.db.user.update({ // All three operations must succeed atomically: if session deletion
where: { email: record.email }, // fails after the password is already changed, old sessions could
data: { passwordHash }, // persist with the new password (CWE-613).
select: { id: true }, await ctx.db.$transaction(async (tx) => {
}); const updatedUser = await tx.user.update({
where: { email: record.email },
data: { passwordHash },
select: { id: true },
});
// Invalidate all active sessions so any session obtained before the await tx.activeSession.deleteMany({ where: { userId: updatedUser.id } });
// password reset cannot be reused (CWE-613).
await ctx.db.activeSession.deleteMany({ where: { userId: updatedUser.id } });
await ctx.db.passwordResetToken.update({ await tx.passwordResetToken.update({
where: { token: input.token }, where: { token: input.token },
data: { usedAt: new Date() }, data: { usedAt: new Date() },
});
}); });
return { success: true }; return { success: true };