fix(security): authorize site uploads and harden tokens, media and request identity
CI / check (push) Successful in 3m15s
CI / deploy (push) Successful in 1m19s
CI / publish-container (push) Successful in 48s

This commit is contained in:
Simo committed 2026-09-13 19:24:43 +02:00
1 parent 52f6d1491f
commit 8abfe352ef
70 files changed
+1609 -204

No files matched your search

@@ -0,0 +1,39 @@
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { parse } from "@babel/parser";
import { expect, it } from "vitest";
it("keeps verification token minting and sending out of public server action exports", () => {
const forbidden = [
"verificationToken",
"isValidVerificationToken",
"sendVerification",
];
const exposed: string[] = [];
for (const file of readdirSync("src/actions").filter(
(file) => file.endsWith(".ts") && !file.endsWith(".test.ts"),
)) {
const ast = parse(readFileSync(path.join("src/actions", file), "utf8"), {
sourceType: "module",
plugins: ["typescript"],
});
if (!ast.program.directives.some((d) => d.value.value === "use server"))
continue;
for (const item of ast.program.body) {
if (item.type !== "ExportNamedDeclaration") continue;
if (
item.declaration?.type === "FunctionDeclaration" &&
forbidden.includes(item.declaration.id?.name ?? "")
)
exposed.push(`${file}:${item.declaration.id?.name}`);
for (const spec of item.specifiers) {
const name =
spec.exported.type === "Identifier"
? spec.exported.name
: spec.exported.value;
if (forbidden.includes(name)) exposed.push(`${file}:${name}`);
}
}
}
expect(exposed).toEqual([]);
});
+125
View File
@@ -0,0 +1,125 @@
import "server-only";
import { createHmac, timingSafeEqual } from "node:crypto";
import { getTranslations } from "next-intl/server";
import { env } from "@/env";
import { resolveHotelName } from "@/lib/hotel-name";
import { sendMail } from "@/lib/services/email";
// Stateless email verification with a time-limited HMAC token.
//
// Token format: `{issuedAtUnix}.{hmacHex}` where
// hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`)
// Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests
// (bare 64-char hex) are rejected.
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
/** Secret mixed into the HMAC. Requires at least one of APP_KEY or AUTH_SECRET. */
function verifySecret(): string {
const secret = env.APP_KEY || env.AUTH_SECRET;
if (!secret)
throw new Error(
"APP_KEY or AUTH_SECRET must be set for email verification",
);
return secret;
}
function sign(email: string, issuedAt: number): string {
return createHmac("sha256", verifySecret())
.update(`${email}|${issuedAt}`)
.digest("hex");
}
/** Compute a fresh verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase();
const issuedAt = Math.floor(Date.now() / 1000);
return `${issuedAt}.${sign(normalised, issuedAt)}`;
}
/**
* Constant-time check that `token` matches a non-expired HMAC for `email`.
* Returns false on format/expiry/signature mismatch rather than throwing.
*/
export async function isValidVerificationToken(
email: string,
token: string,
): Promise<boolean> {
if (!email || !token) return false;
const normalised = email.trim().toLowerCase();
const match = /^(\d+)\.([a-f0-9]{64})$/i.exec(token.trim());
if (!match) return false; // also rejects legacy forever-valid digests
const issuedAt = Number(match[1]);
const sig = match[2]?.toLowerCase() ?? "";
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return false;
const ageMs = Date.now() - issuedAt * 1000;
if (ageMs < 0 || ageMs > TOKEN_TTL_MS) return false;
const expected = sign(normalised, issuedAt);
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(sig, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Build the verification link + email and send it. No-ops gracefully when SMTP
* is unconfigured (sendMail returns false).
*/
export async function sendVerification(email: string): Promise<boolean> {
const normalised = email.trim().toLowerCase();
if (!normalised) return false;
const token = await verificationToken(normalised);
const base = env.APP_URL.replace(/\/+$/, "");
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
const hotelName = await resolveHotelName();
let subject = `Verify your email · ${hotelName}`;
let heading = "Verify your email";
let body = `Welcome to ${hotelName}! Confirm this email address to finish setting up your account.`;
let button = "Verify email";
let fallback =
"If the button doesn't work, paste this link into your browser:";
try {
const t = await getTranslations("emails.verify");
subject = t("subject", { hotel: hotelName });
heading = t("heading");
body = t("body", { hotel: hotelName });
button = t("button");
fallback = t("fallback");
} catch {
/* messages missing — keep English defaults */
}
const html = `
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
<p>${escapeHtml(body)}</p>
<p style="margin:1.25rem 0">
<a href="${link}"
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
${escapeHtml(button)}
</a>
</p>
<p style="color:#64748b;font-size:0.875rem">${escapeHtml(fallback)}</p>
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
</div>
`.trim();
return sendMail(normalised, subject, html);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+38
View File
@@ -0,0 +1,38 @@
export type PersonalTokenAbility =
| "tickets:read"
| "tickets:write"
| "articles:write"
| "radio:read"
| "radio:write"
| "badges:read";
/** Sanctum abilities are JSON; invalid data never grants access. */
export function tokenAllowsAbilities(
encoded: unknown,
required: readonly PersonalTokenAbility[] = [],
): boolean {
if (typeof encoded !== "string") return false;
let abilities: unknown;
try {
abilities = JSON.parse(encoded);
} catch {
return false;
}
if (
!Array.isArray(abilities) ||
abilities.length === 0 ||
!abilities.every(
(ability) =>
typeof ability === "string" &&
ability.length > 0 &&
ability.trim() === ability,
)
)
return false;
if (abilities.includes("*")) return true;
// A caller with no declared scope requires a full-access token.
return (
required.length > 0 &&
required.every((ability) => abilities.includes(ability))
);
}