Fix security scanner findings

- Replace hardcoded test secrets with crypto-generated values in laravel-encrypter.test.ts and totp.test.ts
- Add 'secure' attribute to locale cookie in language-switcher.tsx
- Validate image URLs before rendering in media-grid.tsx and media-picker.tsx (XSS prevention)
- Validate redirect URL is HTTPS before window.location assignment in TopUpForm.tsx (open redirect prevention)
- Document intentional MD5 usage for legacy PHP compatibility in password.ts
- Document HMAC integrity protection for CBC cipher in laravel-encrypter.ts
This commit is contained in:
openhands committed 2026-07-10 23:08:15 +02:00
1 parent 942bc6fc8d
commit 1875a69b83
8 files changed
+46 -12

No files matched your search

+8 -5
View File
@@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
LaravelEncrypter,
@@ -6,7 +7,9 @@ import {
} from "./laravel-encrypter";
// A deterministic 32-byte key in Laravel's "base64:" form.
const APP_KEY = `base64:${Buffer.from("0123456789abcdef0123456789abcdef").toString("base64")}`;
const APP_KEY = `base64:${Buffer.from(
"0123456789abcdef0123456789abcdef",
).toString("base64")}`;
describe("LaravelEncrypter", () => {
it("rejects a key that is not 32 bytes", () => {
@@ -15,10 +18,10 @@ describe("LaravelEncrypter", () => {
it("round-trips encrypt/decrypt (serialize=true, like Laravel encrypt())", () => {
const enc = new LaravelEncrypter(APP_KEY);
const secret = "JBSWY3DPEHPK3PXP"; // a TOTP secret
const payload = enc.encrypt(secret);
expect(payload).not.toContain(secret);
expect(enc.decrypt(payload)).toBe(secret);
const plaintext = randomBytes(16).toString("hex");
const payload = enc.encrypt(plaintext);
expect(payload).not.toContain(plaintext);
expect(enc.decrypt(payload)).toBe(plaintext);
});
it("round-trips encryptString/decryptString (serialize=false)", () => {
+3
View File
@@ -30,6 +30,8 @@ export class LaravelEncrypter {
encrypt(value: string, serialize = true): string {
const iv = randomBytes(16);
const data = serialize ? phpSerializeString(value) : value;
// CBC mode is required for Laravel compatibility. Integrity is provided by
// the HMAC-SHA256 mac (verified by decrypt before any output is returned).
const cipher = createCipheriv("aes-256-cbc", this.key, iv);
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
const ivB64 = iv.toString("base64");
@@ -51,6 +53,7 @@ export class LaravelEncrypter {
throw new Error("The MAC is invalid.");
}
const iv = Buffer.from(json.iv, "base64");
// CBC mode required for Laravel compatibility; MAC already verified above.
const decipher = createDecipheriv("aes-256-cbc", this.key, iv);
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
return serialize ? phpUnserializeString(plain) : plain;
+7 -1
View File
@@ -21,7 +21,13 @@ function hashDriver(): "bcrypt" | "argon2id" {
return process.env.PASSWORD_HASH?.toLowerCase() === "argon2id" ? "argon2id" : "bcrypt";
}
/** Lowercase hex md5 of a UTF-8 string (matches PHP md5()). */
/**
* Lowercase hex md5 of a UTF-8 string (matches PHP md5()).
*
* This is deliberately MD5 to match PHP's md5() output so we can verify legacy
* AtomCMS password hashes during the on-login upgrade path (isMd5Of → checkLogin).
* It is NOT used to hash new passwords and does NOT affect credential security.
*/
export function md5Hex(input: string): string {
return createHash("md5").update(input, "utf8").digest("hex");
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { generateTotp, totpKeyUri, verifyTotp } from "./totp";
import { generateTotp, generateTotpSecret, totpKeyUri, verifyTotp } from "./totp";
const SECRET = "JBSWY3DPEHPK3PXP"; // standard base32 test secret
const SECRET = generateTotpSecret();
describe("totp", () => {
it("verifies the current generated code", () => {