Files
SnapOtter/tests/unit/security-auth-hardening.test.ts
T
SnapOtter 4e64ee2779 fix(security): comprehensive security audit and hardening
Auth: login rate limit 30/min (was 500), global rate limit 1000/min (was
unlimited), password/username max lengths on all Zod schemas, session
invalidation on role change, API key legacy scan bounded to 100 keys.

SVG: hardened regex sanitizer with CDATA stripping, XML entity decoding,
set/animate/iframe/embed blocking, comprehensive data: URI blocking,
use element external href blocking. 11 attack payload fixtures added.

SSRF: fixed DNS rebinding TOCTOU by pinning resolved IPs via custom
HTTP/HTTPS agents. Added 6to4 and NAT64 to blocked IPv6 ranges.

Docker: capability dropping (cap_drop ALL + minimal cap_add), resource
limits (4g/8g mem, 512/1024 pids), healthcheck timeout, password
removed from startup banner, default password warning comments.

Network: CSP and HSTS applied in all environments (not just production),
stack traces removed from all error responses, internal paths stripped
from error details, per-route rate limits on uploads (60/min) and URL
fetches (200/hour).

Files: exclusive temp file creation (O_EXCL), disk space circuit
breaker, per-user storage quotas, settings payload 64KB size guard.

Python sidecar: script name allowlist in dispatcher, minimal environment
for subprocess spawns.

Dependencies: fixed 6 production CVEs (drizzle-orm, fastify, fast-uri,
@fastify/static, next, archiver/lodash). Pinned all GitHub Actions to
SHA hashes.

114 security tests added. Full OWASP Top 10 penetration test matrix
verified against production Docker container (30/30 pass after
hardening).
2026-05-13 21:33:50 +08:00

149 lines
4.8 KiB
TypeScript

/**
* Unit tests for security hardening of auth-related env defaults and Zod schemas.
*
* These tests verify that:
* - Rate limit and login attempt defaults were lowered to secure values
* - Auth Zod schemas enforce max length on username/password fields
* - New storage env vars have correct defaults
*/
import { describe, expect, it } from "vitest";
import { loadEnv } from "../../apps/api/src/lib/env.js";
import {
changePasswordSchema,
loginSchema,
registerSchema,
resetPasswordSchema,
} from "../../apps/api/src/plugins/auth.js";
// ── Env defaults ─────────────────────────────────────────────────────────────
describe("Security: env defaults", () => {
it("LOGIN_ATTEMPT_LIMIT defaults to 30", () => {
const env = loadEnv();
expect(env.LOGIN_ATTEMPT_LIMIT).toBe(30);
});
it("RATE_LIMIT_PER_MIN is parsed correctly (test env overrides to 10000)", () => {
// vitest.config.ts sets RATE_LIMIT_PER_MIN=10000 for tests
const env = loadEnv();
expect(env.RATE_LIMIT_PER_MIN).toBe(10000);
});
it("MAX_STORAGE_PER_USER_MB defaults to 5000", () => {
const env = loadEnv();
expect(env.MAX_STORAGE_PER_USER_MB).toBe(5000);
});
it("MAX_WORKSPACE_SIZE_GB defaults to 10", () => {
const env = loadEnv();
expect(env.MAX_WORKSPACE_SIZE_GB).toBe(10);
});
});
// ── Auth Zod schema max length enforcement ───────────────────────────────────
describe("Security: loginSchema max lengths", () => {
it("rejects username longer than 255 chars", () => {
const result = loginSchema.safeParse({
username: "a".repeat(256),
password: "ValidPass1",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.message === "Username too long")).toBe(true);
}
});
it("accepts username at exactly 255 chars", () => {
const result = loginSchema.safeParse({
username: "a".repeat(255),
password: "ValidPass1",
});
expect(result.success).toBe(true);
});
it("rejects password longer than 1024 chars", () => {
const result = loginSchema.safeParse({
username: "testuser",
password: "a".repeat(1025),
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.message === "Password too long")).toBe(true);
}
});
it("accepts password at exactly 1024 chars", () => {
const result = loginSchema.safeParse({
username: "testuser",
password: "a".repeat(1024),
});
expect(result.success).toBe(true);
});
});
describe("Security: changePasswordSchema max lengths", () => {
it("rejects oversized currentPassword", () => {
const result = changePasswordSchema.safeParse({
currentPassword: "a".repeat(1025),
newPassword: "ValidPass1",
});
expect(result.success).toBe(false);
});
it("rejects oversized newPassword", () => {
const result = changePasswordSchema.safeParse({
currentPassword: "ValidPass1",
newPassword: "a".repeat(1025),
});
expect(result.success).toBe(false);
});
});
describe("Security: registerSchema max lengths", () => {
it("rejects username longer than 255 chars", () => {
const result = registerSchema.safeParse({
username: "a".repeat(256),
password: "ValidPass1",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.message === "Username too long")).toBe(true);
}
});
it("rejects password longer than 1024 chars", () => {
const result = registerSchema.safeParse({
username: "testuser",
password: "a".repeat(1025),
});
expect(result.success).toBe(false);
});
});
describe("Security: resetPasswordSchema max lengths", () => {
it("rejects newPassword longer than 1024 chars", () => {
const result = resetPasswordSchema.safeParse({
newPassword: "a".repeat(1025),
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.message === "Password too long")).toBe(true);
}
});
});
describe("Security: all schemas accept valid input", () => {
it("all schemas pass with normal-length fields", () => {
expect(loginSchema.safeParse({ username: "admin", password: "Pass1234" }).success).toBe(true);
expect(
changePasswordSchema.safeParse({ currentPassword: "Pass1234", newPassword: "NewPass1" })
.success,
).toBe(true);
expect(registerSchema.safeParse({ username: "newuser", password: "Pass1234" }).success).toBe(
true,
);
expect(resetPasswordSchema.safeParse({ newPassword: "Pass1234" }).success).toBe(true);
});
});