chore: patch deps, CSP style nonces, otplib 13, and PR CI
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m35s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 20:46:02 +02:00
1 parent 87bda8dbef
commit 2ff08e5127
11 files changed
+239 -160

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
name: CI
on:
pull_request:
branches:
- main
jobs:
check:
runs-on: shell
steps:
- name: Typecheck, lint, and test
run: |
set -e
WORK="$(mktemp -d /var/tmp/epicnext-ci.XXXXXX)"
cleanup() { rm -rf "${WORK}"; }
trap cleanup EXIT
echo "--- CI checks (${WORK}) ---"
git clone --depth 50 \
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
"${WORK}"
cd "${WORK}"
REF="${{ gitea.sha }}"
if [ -z "${REF}" ]; then
echo "ERROR: missing gitea.sha" >&2
exit 1
fi
git fetch --depth 50 origin "${REF}"
git checkout -f "${REF}"
export SKIP_ENV_VALIDATION=1
pnpm install --frozen-lockfile
pnpm prisma:generate
pnpm biome:lint
pnpm typecheck
pnpm test
echo "--- CI checks passed ---"
+3 -2
View File
@@ -1,12 +1,13 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"ignoreUnknown": false "ignoreUnknown": false,
"includes": ["**", "!setup", "!public"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
+7 -7
View File
@@ -31,7 +31,7 @@
"@prisma/adapter-mariadb": "^7.9.0", "@prisma/adapter-mariadb": "^7.9.0",
"@prisma/client": "^7.9.0", "@prisma/client": "^7.9.0",
"@sentry/nextjs": "^10.67.0", "@sentry/nextjs": "^10.67.0",
"@tanstack/react-virtual": "^3.14.6", "@tanstack/react-virtual": "^3.14.7",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -47,18 +47,18 @@
"lucide-react": "^1.25.0", "lucide-react": "^1.25.0",
"lzma": "^2.3.2", "lzma": "^2.3.2",
"music-metadata": "^11.14.0", "music-metadata": "^11.14.0",
"mysql2": "^3.23.0", "mysql2": "^3.23.1",
"next": "^16.2.11", "next": "^16.2.11",
"next-auth": "5.0.0-beta.32", "next-auth": "5.0.0-beta.32",
"next-intl": "^4.13.3", "next-intl": "^4.13.3",
"next-view-transitions": "^0.3.5", "next-view-transitions": "^0.3.5",
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"otplib": "^12.0.1", "otplib": "^13.4.1",
"pino": "^10.3.1", "pino": "^10.3.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.82.0",
"resend": "^6.17.2", "resend": "^6.18.0",
"sanitize-html": "^2.17.6", "sanitize-html": "^2.17.6",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.3", "sharp": "^0.35.3",
@@ -68,7 +68,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.4", "@biomejs/biome": "2.5.5",
"@next/bundle-analyzer": "^16.2.11", "@next/bundle-analyzer": "^16.2.11",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
@@ -83,7 +83,7 @@
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"knip": "^6.27.0", "knip": "^6.27.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postcss": "^8.5.19", "postcss": "^8.5.21",
"prisma": "^7.9.0", "prisma": "^7.9.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tsx": "^4.23.1", "tsx": "^4.23.1",
+142 -130
View File
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,4 @@
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
+1 -1
View File
@@ -87,7 +87,7 @@ export default async function RootLayout({
style={{ backgroundColor: "var(--color-background)" }} style={{ backgroundColor: "var(--color-background)" }}
> >
<NextIntlClientProvider locale={locale} messages={messages}> <NextIntlClientProvider locale={locale} messages={messages}>
<ThemeVars /> <ThemeVars nonce={nonce} />
<ViewTransitions> <ViewTransitions>
{children} {children}
</ViewTransitions> </ViewTransitions>
+6 -3
View File
@@ -9,7 +9,7 @@ import {
// Injects the DB-driven CSS custom properties into :root / html.dark. // Injects the DB-driven CSS custom properties into :root / html.dark.
// Readable (WCAG) text colors are derived centrally in themePaletteCss. // Readable (WCAG) text colors are derived centrally in themePaletteCss.
export async function ThemeVars() { export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
const g = (k: string, d: string) => siteSettings.get(k, d); const g = (k: string, d: string) => siteSettings.get(k, d);
const [ const [
primary, primary,
@@ -175,10 +175,13 @@ export async function ThemeVars() {
return ( return (
<> <>
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null} {googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
<style dangerouslySetInnerHTML={{ __html: css }} /> <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
{customCss?.trim() ? ( {customCss?.trim() ? (
// Staff-authored custom CSS (housekeeping → Theme). Trusted input. // Staff-authored custom CSS (housekeeping → Theme). Trusted input.
<style dangerouslySetInnerHTML={{ __html: customCss }} /> <style
nonce={nonce}
dangerouslySetInnerHTML={{ __html: customCss }}
/>
) : null} ) : null}
</> </>
); );
+28 -8
View File
@@ -1,13 +1,29 @@
import { authenticator } from "otplib"; import {
createGuardrails,
generateSecret,
generateSync,
generateURI,
verifySync,
} from "otplib";
// Laravel Fortify uses pragmarx/google2fa: HMAC-SHA1, 6 digits, 30s period. // Laravel Fortify / pragmarx google2fa: HMAC-SHA1, 6 digits, 30s period.
// otplib already defaults to SHA1/6/30; window=1 tolerates one step of skew. // Allow legacy secrets shorter than otplib v13's default 16-byte minimum
authenticator.options = { window: 1 }; // (many existing AtomCMS/Fortify secrets decode to ~10 bytes).
const guardrails = createGuardrails({ MIN_SECRET_BYTES: 10 });
// ±1 time-step of clock skew (30s period → epochTolerance 30).
const EPOCH_TOLERANCE = 30;
/** Verify a 6-digit TOTP code against a base32 secret. */ /** Verify a 6-digit TOTP code against a base32 secret. */
export function verifyTotp(token: string, secret: string): boolean { export function verifyTotp(token: string, secret: string): boolean {
try { try {
return authenticator.check(token, secret); const result = verifySync({
secret,
token,
epochTolerance: EPOCH_TOLERANCE,
guardrails,
});
return result.valid === true;
} catch { } catch {
return false; return false;
} }
@@ -15,12 +31,12 @@ export function verifyTotp(token: string, secret: string): boolean {
/** Current TOTP code for a secret (used in tests / tooling). */ /** Current TOTP code for a secret (used in tests / tooling). */
export function generateTotp(secret: string): string { export function generateTotp(secret: string): string {
return authenticator.generate(secret); return generateSync({ secret, guardrails });
} }
/** Generate a fresh base32 secret for enrolling a new authenticator. */ /** Generate a fresh base32 secret for enrolling a new authenticator. */
export function generateTotpSecret(): string { export function generateTotpSecret(): string {
return authenticator.generateSecret(); return generateSecret();
} }
/** otpauth:// URI for provisioning a QR code. */ /** otpauth:// URI for provisioning a QR code. */
@@ -29,5 +45,9 @@ export function totpKeyUri(
accountName: string, accountName: string,
issuer: string, issuer: string,
): string { ): string {
return authenticator.keyuri(accountName, issuer, secret); return generateURI({
issuer,
label: accountName,
secret,
});
} }
+3 -1
View File
@@ -13,7 +13,9 @@ describe("csp", () => {
expect(csp).toContain("script-src"); expect(csp).toContain("script-src");
expect(csp).toContain("'nonce-testNonce123'"); expect(csp).toContain("'nonce-testNonce123'");
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/); expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
expect(csp).toContain("style-src 'self' 'unsafe-inline'"); expect(csp).toContain("style-src 'self' 'nonce-testNonce123'");
expect(csp).not.toMatch(/style-src(?!-attr)[^;]*'unsafe-inline'/);
expect(csp).toContain("style-src-attr 'unsafe-inline'");
expect(csp).toContain("https://challenges.cloudflare.com"); expect(csp).toContain("https://challenges.cloudflare.com");
expect(csp).toContain("https://cdn.jsdelivr.net"); expect(csp).toContain("https://cdn.jsdelivr.net");
}); });
+11 -5
View File
@@ -1,8 +1,7 @@
/** /**
* Build a Content-Security-Policy value. * Build a Content-Security-Policy value.
* Scripts: nonce for Next.js / first-party inline; host allowlists for captcha, * Scripts/styles: per-request nonce for Next.js and ThemeVars inline tags.
* Cloudflare Insights, and TinyMCE CDN. style-src keeps 'unsafe-inline' for * style-src-attr keeps 'unsafe-inline' so React `style={{…}}` attributes work.
* theme CSS variables (ThemeVars) — nonce styles are a follow-up.
*/ */
export function buildContentSecurityPolicy(nonce: string): string { export function buildContentSecurityPolicy(nonce: string): string {
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
@@ -17,14 +16,21 @@ export function buildContentSecurityPolicy(nonce: string): string {
...(isDev ? ["'unsafe-eval'"] : []), ...(isDev ? ["'unsafe-eval'"] : []),
].join(" "); ].join(" ");
const styleSrc = [
"'self'",
`'nonce-${nonce}'`,
"https://fonts.googleapis.com",
].join(" ");
return [ return [
"default-src 'self'", "default-src 'self'",
`script-src ${scriptSrc}`, `script-src ${scriptSrc}`,
"style-src 'self' 'unsafe-inline'", `style-src ${styleSrc}`,
"style-src-attr 'unsafe-inline'",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/", "frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
"connect-src 'self' https: wss:", "connect-src 'self' https: wss:",
"font-src 'self' data:", "font-src 'self' data: https://fonts.gstatic.com",
"object-src 'none'", "object-src 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",
-2
View File
@@ -11,8 +11,6 @@ import { prisma } from "./prisma";
// Re-export PERMS from the standalone file (safe for client components) // Re-export PERMS from the standalone file (safe for client components)
export { PERMS } from "./permission-slugs"; export { PERMS } from "./permission-slugs";
import { PERMS } from "./permission-slugs";
// ── Permission Set ────────────────────────────────────────────────── // ── Permission Set ──────────────────────────────────────────────────
export type { PermissionSet } from "@/types/admin"; export type { PermissionSet } from "@/types/admin";