chore: patch deps, CSP style nonces, otplib 13, and PR CI
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
87bda8dbef
commit
2ff08e5127
11 files changed
+239
-160
No files matched your search
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+142
-130
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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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'",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in new issue
Block a user