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:
diegosouzapw
2026-05-21 01:02:06 -03:00
parent 0a1c3f8fe6
commit 8ce81a067c
4 changed files with 38 additions and 4 deletions

View File

@@ -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");

View File

@@ -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",
});

View File

@@ -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");

View File

@@ -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");