mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(security): pin GCM authTagLength and harden Mermaid SVG rendering
Close two defense-in-depth gaps surfaced by the v3.8.1 release vulnerability scan: - Field-level credential encryption (src/lib/db/encryption.ts) and the CLI decrypt path (bin/cli/encryption.mjs) now pass authTagLength: 16 to every createDecipheriv call. Authentication was already enforced via setAuthTag + final(), but pinning the tag length rejects truncated GCM tags up front, closing the tag-truncation forgery vector (Semgrep gcm-no-tag-length). - MermaidDiagram switches securityLevel from "loose" to "strict" (Mermaid's default), so the rendered SVG is sanitized by Mermaid's bundled DOMPurify before the innerHTML assignment. Adds a regression test asserting decrypt() fails closed on a truncated auth tag.
This commit is contained in:
@@ -3,6 +3,9 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
// Full 16-byte GCM tag produced by cipher.getAuthTag(). Pinning authTagLength on
|
||||
// decrypt rejects truncated tags, closing the GCM tag-truncation forgery vector.
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const PREFIX = "enc:v1:";
|
||||
// Keep this salt in sync with the app-side field encryption format so credentials written by
|
||||
// CLI setup remain decryptable by the dashboard/server and vice versa.
|
||||
@@ -53,7 +56,9 @@ export function decryptCredential(value) {
|
||||
}
|
||||
|
||||
const [ivHex, encryptedHex, authTagHex] = parts;
|
||||
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex"));
|
||||
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex"), {
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
});
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
||||
|
||||
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
||||
|
||||
@@ -20,7 +20,10 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "default",
|
||||
securityLevel: "loose",
|
||||
// "strict" (Mermaid's default) sanitizes rendered SVG via its bundled
|
||||
// DOMPurify before we assign it to innerHTML, blocking XSS from any
|
||||
// untrusted diagram source. Avoid "loose" (allows HTML/click in labels).
|
||||
securityLevel: "strict",
|
||||
fontFamily: "inherit",
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash }
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
/**
|
||||
* GCM authentication tag length, in bytes. Pinned to the full 16-byte tag
|
||||
* produced by `cipher.getAuthTag()`. Passing `authTagLength` to
|
||||
* `createDecipheriv` rejects truncated authentication tags up front, closing
|
||||
* the GCM tag-truncation forgery vector (Semgrep gcm-no-tag-length).
|
||||
*/
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const PREFIX = "enc:v1:";
|
||||
const STATIC_SALT = "omniroute-field-encryption-v1";
|
||||
|
||||
@@ -167,7 +174,9 @@ export function decrypt(ciphertext: string | null | undefined): string | null |
|
||||
try {
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const authTag = Buffer.from(authTagHex, "hex");
|
||||
const decipher = createDecipheriv(ALGORITHM, candidateKey, iv);
|
||||
const decipher = createDecipheriv(ALGORITHM, candidateKey, iv, {
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
});
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
||||
@@ -295,7 +304,9 @@ export function migrateLegacyEncryptedString(ciphertext: string | null | undefin
|
||||
|
||||
const tryDecryptWithKey = (key: Buffer): string | null => {
|
||||
try {
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
});
|
||||
decipher.setAuthTag(authTag);
|
||||
let decrypted = decipher.update(encrypted, undefined, "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
@@ -54,6 +54,21 @@ test("encrypt/decrypt round-trip uses the expected serialized format", async ()
|
||||
assert.equal(encryption.encrypt(encrypted), encrypted);
|
||||
});
|
||||
|
||||
test("decrypt rejects a truncated GCM authentication tag (authTagLength pinned to 16 bytes)", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-authtag";
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const encrypted = encryption.encrypt("forge-me");
|
||||
assert.equal(encryption.decrypt(encrypted), "forge-me");
|
||||
|
||||
// Truncate the trailing auth tag to 1 byte (2 hex chars). With authTagLength
|
||||
// pinned to 16 bytes on createDecipheriv, Node rejects the short tag instead
|
||||
// of verifying a weakened tag, so decrypt must fail closed (null).
|
||||
const [prefix, version, ivHex, encryptedHex] = encrypted.split(":");
|
||||
const truncated = `${prefix}:${version}:${ivHex}:${encryptedHex}:ab`;
|
||||
assert.equal(encryption.decrypt(truncated), null);
|
||||
});
|
||||
|
||||
test("connection field helpers encrypt and decrypt all supported credential fields", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-b";
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
Reference in New Issue
Block a user