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>
79 lines
2.8 KiB
TypeScript
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");
|
|
}
|