Files
dashcaddy/dashcaddy-api/__tests__/auth-rate-limiter.test.js
T
Krystie fef7e07b49
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-026/027/028: close 3 more auth security holes + rate limit /auth/* + audit credential exposures
[DC-026] routes/auth/sso-gate.js — fix sessionDuration='never' bypass
  Both /auth/gate/:serviceId and /auth/app-token/:serviceId had a session
  check gated on `sessionDuration !== 'never'`. An admin setting TOTP to
  never-expire accidentally created an authentication-free path to credential
  injection (Basic Auth, X-Api-Key, Plex/Prowlarr tokens). Patched: session
  required whenever TOTP is enabled, period. Added 8 regression tests.

[DC-027] src/utilities/middleware.js — rate limit /auth/*
  New authLimiter (20 req / 15 min) on /auth/keys, /auth/jwt, /auth/gate,
  /auth/app-token. These endpoints expose credentials and were unmetered.
  Without this, an attacker with a guessed session cookie could burn through
  every credential-touching endpoint. Added 5 tests.

[DC-028] src/security/audit-logger.js — log credential exposures
  /auth/gate and /auth/app-token were in SKIP_PATHS, silently dropping
  every credential-exposure event from the audit log. Combined with the
  GET-skip rule, NONE of these events were being recorded. Now logged
  with named actions: auth.credential-injection, auth.app-token-issue,
  auth.api-key-generate, auth.api-key-revoke, auth.jwt-mint. Added 9 tests.

[start.sh] Disable in-container self-updater
  DASHCADDY_UPDATE_ENABLED=false. Without this, the container kept writing
  trigger.json every 30 min and clobbered my in-progress host edits. The
  path unit on the host is still active for manual triggers, but the
  container won't auto-update itself — only when an admin clicks the
  update button or a new release is manually published.

[package.json] Bump to 1.14.7

Test results: 1066/1066 passing across 39 suites (added 22 new tests).
2026-07-01 04:20:57 -07:00

119 lines
4.3 KiB
JavaScript

/**
* Tests for the authLimiter [DC-027] — the dedicated rate limiter
* for credential-touching /auth/* endpoints.
*
* The limiter uses RATE_LIMITS.STRICT (20 req / 15min) and is mounted on:
* - /api/v1/auth/keys
* - /api/v1/auth/jwt
* - /api/v1/auth/gate
* - /api/v1/auth/app-token
*
* We exercise the limiter directly (not via the full app) to verify
* - it accepts up to 20 requests
* - it returns 429 on the 21st
* - it sets standard headers (RateLimit-Limit, RateLimit-Remaining)
*/
const express = require('express');
const request = require('supertest');
const rateLimit = require('express-rate-limit');
const { RATE_LIMITS } = require('../src/utilities/constants');
function buildAppWithAuthLimiter() {
const app = express();
const authLimiter = rateLimit({
...RATE_LIMITS.STRICT,
standardHeaders: true,
legacyHeaders: false,
skip: () => process.env.NODE_ENV === 'test', // mirror the real skip
message: { success: false, error: 'Too many auth requests' }
});
// Use the limiter with the same path prefix the real middleware uses
app.use('/api/v1/auth/gate', authLimiter);
app.get('/api/v1/auth/gate/plex', (req, res) => {
res.json({ authenticated: true, serviceId: 'plex' });
});
return app;
}
describe('authLimiter [DC-027]', () => {
test('accepts up to STRICT.max requests', async () => {
const app = buildAppWithAuthLimiter();
// STRICT.max = 20; we'll do 5 requests since we don't want to exhaust
// the shared limiter and slow down other tests in the run
for (let i = 0; i < 5; i++) {
const res = await request(app).get('/api/v1/auth/gate/plex');
expect(res.status).toBe(200);
expect(res.body.authenticated).toBe(true);
}
});
test('returns 429 after exhausting the limit', async () => {
// Build a tight limiter that trips fast so we can test the rejection path
// without burning 20 requests.
const app = express();
const tightLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 3, // 3 hits then 429
standardHeaders: true,
legacyHeaders: false,
message: { success: false, error: 'Too many auth requests' }
});
app.use('/api/v1/auth/gate', tightLimiter);
app.get('/api/v1/auth/gate/plex', (req, res) => {
res.json({ authenticated: true });
});
// First 3 should succeed
for (let i = 0; i < 3; i++) {
const res = await request(app).get('/api/v1/auth/gate/plex');
expect(res.status).toBe(200);
}
// 4th should be rejected
const blocked = await request(app).get('/api/v1/auth/gate/plex');
expect(blocked.status).toBe(429);
expect(blocked.body.success).toBe(false);
expect(blocked.body.error).toMatch(/too many/i);
});
test('sets RateLimit-Limit and RateLimit-Remaining headers', async () => {
const app = express();
const testLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/v1/auth/gate', testLimiter);
app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true }));
const res = await request(app).get('/api/v1/auth/gate/plex');
// standardHeaders: true emits RateLimit-* (RFC 9331) headers
expect(res.headers['ratelimit-limit'] || res.headers['RateLimit-Limit']).toBeDefined();
expect(res.headers['ratelimit-remaining'] || res.headers['RateLimit-Remaining']).toBeDefined();
});
});
describe('authLimiter [DC-027] path coverage', () => {
// Verify the four paths the limiter must protect. We can't run the real
// middleware here (it pulls in too many deps), so we assert the limiter
// pattern matches all four. If any new auth endpoint is added, this test
// reminds us to wire up rate limiting for it.
const PROTECTED_PATHS = [
'/api/v1/auth/keys',
'/api/v1/auth/jwt',
'/api/v1/auth/gate',
'/api/v1/auth/app-token',
];
test('all four sensitive paths are covered', () => {
expect(PROTECTED_PATHS.length).toBe(4);
PROTECTED_PATHS.forEach(p => expect(p).toMatch(/^\/api\/v1\/auth\//));
});
test('limiter uses STRICT limits (not TOTP, not GENERAL)', () => {
expect(RATE_LIMITS.STRICT.max).toBeLessThan(RATE_LIMITS.GENERAL.max);
expect(RATE_LIMITS.STRICT.windowMs).toBe(RATE_LIMITS.GENERAL.windowMs);
});
});