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
@@ -26,7 +26,7 @@ function makeDb(
resetToken?: Partial<Record<string, unknown>>;
} = {},
) {
return {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", email: "user@example.com" }),
update: vi.fn().mockResolvedValue({ id: "user_1" }),
@@ -42,7 +42,9 @@ function makeDb(
update: vi.fn().mockResolvedValue({}),
...overrides.resetToken,
},
$transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn(db)),
} as never;
return db;
}
function makeCtx(db = makeDb()) {
+18 -14
View File
@@ -47,18 +47,20 @@ describe("rate limiter", () => {
vi.doMock("ioredis", () => ({
Redis: vi.fn().mockImplementation(() => ({
on: vi.fn(),
eval: vi.fn(async (_script: string, _numKeys: number, key: string, windowMsValue: string) => {
const now = Date.now();
const windowMs = Number(windowMsValue);
const existing = store.get(key);
if (!existing || existing.resetAt <= now) {
store.set(key, { count: 1, resetAt: now + windowMs });
} else {
existing.count += 1;
}
const current = store.get(key)!;
return [current.count, current.resetAt - now];
}),
eval: vi.fn(
async (_script: string, _numKeys: number, key: string, windowMsValue: string) => {
const now = Date.now();
const windowMs = Number(windowMsValue);
const existing = store.get(key);
if (!existing || existing.resetAt <= now) {
store.set(key, { count: 1, resetAt: now + windowMs });
} else {
existing.count += 1;
}
const current = store.get(key)!;
return [current.count, current.resetAt - now];
},
),
scan: vi.fn(async () => ["0", [...store.keys()]]),
del: delMock,
})),
@@ -88,7 +90,7 @@ describe("rate limiter", () => {
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", () => ({
Redis: vi.fn().mockImplementation(() => ({
on: vi.fn(),
@@ -101,7 +103,9 @@ describe("rate limiter", () => {
}));
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",
redisUrl: "redis://test",
name: "redis-fallback-test",