fix(security): authorize site uploads and harden tokens, media and request identity
This commit is contained in:
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([]);
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user