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:
1 parent
942bc6fc8d
commit
1875a69b83
8 files changed
+46
-12
No files matched your search
@@ -42,7 +42,11 @@ export default function TopUpForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Hand off to PayPal for approval.
|
// Hand off to PayPal for approval.
|
||||||
window.location.href = data.approveUrl;
|
const redirectUrl = new URL(data.approveUrl);
|
||||||
|
if (redirectUrl.protocol !== "https:") {
|
||||||
|
throw new Error("Invalid redirect URL: must be HTTPS");
|
||||||
|
}
|
||||||
|
window.location.href = redirectUrl.href;
|
||||||
} catch {
|
} catch {
|
||||||
setError("Network error — please try again.");
|
setError("Network error — please try again.");
|
||||||
setPending(false);
|
setPending(false);
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { uploadMedia } from "@/actions/admin-media";
|
|||||||
|
|
||||||
type MediaFile = { name: string; url: string };
|
type MediaFile = { name: string; url: string };
|
||||||
|
|
||||||
|
function validImageUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
return u.protocol === "http:" || u.protocol === "https:" ? u.href : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminMediaGrid() {
|
export function AdminMediaGrid() {
|
||||||
const [files, setFiles] = useState<MediaFile[]>([]);
|
const [files, setFiles] = useState<MediaFile[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -87,7 +96,7 @@ export function AdminMediaGrid() {
|
|||||||
{files.map((f) => (
|
{files.map((f) => (
|
||||||
<div key={f.name} className="border border-[var(--color-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--color-background)]">
|
<div key={f.name} className="border border-[var(--color-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--color-background)]">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={f.url} alt={f.name} className="w-full h-[120px] object-cover block" />
|
<img src={validImageUrl(f.url)} alt={f.name} className="w-full h-[120px] object-cover block" />
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<p className="text-xs text-[var(--color-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
<p className="text-xs text-[var(--color-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
{f.name}
|
{f.name}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { uploadMediaAndReturn } from "@/actions/admin-media";
|
|||||||
|
|
||||||
type MediaFile = { name: string; url: string };
|
type MediaFile = { name: string; url: string };
|
||||||
|
|
||||||
|
function validImageUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
return u.protocol === "http:" || u.protocol === "https:" ? u.href : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function MediaPicker({
|
export function MediaPicker({
|
||||||
onSelect,
|
onSelect,
|
||||||
current,
|
current,
|
||||||
@@ -109,7 +118,7 @@ export function MediaPicker({
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={f.url}
|
src={validImageUrl(f.url)}
|
||||||
alt={f.name}
|
alt={f.name}
|
||||||
className="w-full h-[100px] object-cover block"
|
className="w-full h-[100px] object-cover block"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function LanguageSwitcher() {
|
|||||||
|
|
||||||
function switchLocale(code: string) {
|
function switchLocale(code: string) {
|
||||||
if (code === locale) return;
|
if (code === locale) return;
|
||||||
document.cookie = `NEXT_LOCALE=${code};path=/;max-age=31536000;samesite=lax`;
|
document.cookie = `NEXT_LOCALE=${code};path=/;max-age=31536000;samesite=lax;secure`;
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
LaravelEncrypter,
|
LaravelEncrypter,
|
||||||
@@ -6,7 +7,9 @@ import {
|
|||||||
} from "./laravel-encrypter";
|
} from "./laravel-encrypter";
|
||||||
|
|
||||||
// A deterministic 32-byte key in Laravel's "base64:" form.
|
// 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", () => {
|
describe("LaravelEncrypter", () => {
|
||||||
it("rejects a key that is not 32 bytes", () => {
|
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())", () => {
|
it("round-trips encrypt/decrypt (serialize=true, like Laravel encrypt())", () => {
|
||||||
const enc = new LaravelEncrypter(APP_KEY);
|
const enc = new LaravelEncrypter(APP_KEY);
|
||||||
const secret = "JBSWY3DPEHPK3PXP"; // a TOTP secret
|
const plaintext = randomBytes(16).toString("hex");
|
||||||
const payload = enc.encrypt(secret);
|
const payload = enc.encrypt(plaintext);
|
||||||
expect(payload).not.toContain(secret);
|
expect(payload).not.toContain(plaintext);
|
||||||
expect(enc.decrypt(payload)).toBe(secret);
|
expect(enc.decrypt(payload)).toBe(plaintext);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips encryptString/decryptString (serialize=false)", () => {
|
it("round-trips encryptString/decryptString (serialize=false)", () => {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export class LaravelEncrypter {
|
|||||||
encrypt(value: string, serialize = true): string {
|
encrypt(value: string, serialize = true): string {
|
||||||
const iv = randomBytes(16);
|
const iv = randomBytes(16);
|
||||||
const data = serialize ? phpSerializeString(value) : value;
|
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 cipher = createCipheriv("aes-256-cbc", this.key, iv);
|
||||||
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||||
const ivB64 = iv.toString("base64");
|
const ivB64 = iv.toString("base64");
|
||||||
@@ -51,6 +53,7 @@ export class LaravelEncrypter {
|
|||||||
throw new Error("The MAC is invalid.");
|
throw new Error("The MAC is invalid.");
|
||||||
}
|
}
|
||||||
const iv = Buffer.from(json.iv, "base64");
|
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 decipher = createDecipheriv("aes-256-cbc", this.key, iv);
|
||||||
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||||
return serialize ? phpUnserializeString(plain) : plain;
|
return serialize ? phpUnserializeString(plain) : plain;
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ function hashDriver(): "bcrypt" | "argon2id" {
|
|||||||
return process.env.PASSWORD_HASH?.toLowerCase() === "argon2id" ? "argon2id" : "bcrypt";
|
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 {
|
export function md5Hex(input: string): string {
|
||||||
return createHash("md5").update(input, "utf8").digest("hex");
|
return createHash("md5").update(input, "utf8").digest("hex");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("totp", () => {
|
||||||
it("verifies the current generated code", () => {
|
it("verifies the current generated code", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user