Files
caddy-proxy-manager/src/lib/secret.ts
fuomag9 debd0d98fc security: fix 17 vulnerabilities from comprehensive pentest
Fixes identified from full security audit covering auth, crypto,
injection, infrastructure, and configuration security.

Critical:
- C1: Fail-closed on unrecognized NODE_ENV (prevent DEV_SECRET in staging)
- C3: Validate API token expires_at (reject invalid dates that bypass expiry)

High:
- H1: Refresh JWT role from DB on each session (reflect demotions immediately)
- H2: Docker socket proxy for l4-port-manager (restrict API surface)
- H5: Block dangerous WAF custom directives (SecRuleEngine, SecAuditEngine)
- H7: Require explicit NEXTAUTH_TRUST_HOST instead of always trusting Host
- H8: Semantic validation of sync payload (block metadata SSRF, size limits)

Medium:
- M3: Rate limit password change current-password verification
- M5: Parameterized SQL in log/waf parsers (replace template literals)
- M6: Nonce-based CSP replacing unsafe-inline for script-src
- M9: Strip Caddy placeholders from rewrite path_prefix
- M10: Sanitize authentik outpostDomain (path traversal, placeholders)
- M14: Deny access on missing JWT role instead of defaulting to "user"

Low:
- L1: Require Origin header on mutating session-authenticated requests
- L4: Enforce password complexity on user password changes
- L5: Time-limited legacy SHA-256 key fallback (grace period until 2026-06-01)
- L6: Escape LIKE metacharacters in audit log search
- L7: Runtime-validate WAF excluded_rule_ids as positive integers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:14:44 +01:00

79 lines
2.8 KiB
TypeScript

import { hkdfSync, createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { config } from "./config";
const PREFIX = "enc:v1:";
const IV_LENGTH = 12;
function deriveKey(): Buffer {
return Buffer.from(
hkdfSync("sha256", config.sessionSecret, Buffer.alloc(0), "caddy-proxy-manager:secret:v1", 32)
);
}
function deriveKeyLegacy(): Buffer {
return createHash("sha256").update(config.sessionSecret).digest();
}
export function isEncryptedSecret(value: string): boolean {
return value.startsWith(PREFIX);
}
export function encryptSecret(value: string): string {
if (!value) return "";
if (isEncryptedSecret(value)) return value;
const iv = randomBytes(IV_LENGTH);
const key = deriveKey();
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${ciphertext.toString("base64")}`;
}
/**
* L5: Legacy fallback is time-limited. After the migration grace period,
* the legacy key is no longer tried, forcing re-encryption of old secrets.
* Set LEGACY_KEY_CUTOFF_DATE env var to extend/disable (ISO 8601 date or "never").
*/
const LEGACY_KEY_CUTOFF_ENV = process.env.LEGACY_KEY_CUTOFF_DATE;
const LEGACY_KEY_CUTOFF = LEGACY_KEY_CUTOFF_ENV === "never"
? null
: new Date(LEGACY_KEY_CUTOFF_ENV || "2026-06-01T00:00:00Z");
export function decryptSecret(value: string): string {
if (!value) return "";
if (!isEncryptedSecret(value)) return value;
// Try new HKDF key first
try {
return _decryptWithKey(value, deriveKey());
} catch (hkdfError) {
// L5: Only fall back to legacy key within the grace period
if (LEGACY_KEY_CUTOFF && new Date() > LEGACY_KEY_CUTOFF) {
throw new Error(
"[secret] HKDF decryption failed and legacy key grace period has expired. " +
"Re-encrypt this secret with the current key. " +
"Set LEGACY_KEY_CUTOFF_DATE=never to temporarily restore legacy key support."
);
}
console.warn("[secret] HKDF decryption failed; retrying with legacy SHA-256 key. Re-encrypt this secret to remove the legacy key dependency.");
return _decryptWithKey(value, deriveKeyLegacy());
}
}
function _decryptWithKey(value: string, key: Buffer): string {
const payload = value.slice(PREFIX.length);
const [ivB64, tagB64, dataB64] = payload.split(":");
if (!ivB64 || !tagB64 || !dataB64) {
throw new Error("Invalid encrypted secret format");
}
const iv = Buffer.from(ivB64, "base64");
const tag = Buffer.from(tagB64, "base64");
const data = Buffer.from(dataB64, "base64");
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(data), decipher.final()]);
return plaintext.toString("utf8");
}