Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 7 additions & 34 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,20 @@
import type { NextConfig } from "next";

const securityHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "X-XSS-Protection", value: "1; mode=block" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" },
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
// Allow Turnstile + Next.js inline scripts (Next injects inline scripts)
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com",
// Allow Tailwind-injected styles + Google Fonts
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
// Allow Google Fonts woff2 + Turnstile font assets
"font-src 'self' https://fonts.gstatic.com data: https://challenges.cloudflare.com",
// Allow our images + Turnstile injected pixel/favicon
"img-src 'self' data: blob: https://challenges.cloudflare.com",
// Turnstile connects to challenges.cloudflare.com for widget + validation
"connect-src 'self' https://horizon-testnet.stellar.org https://api.stellar.org https://challenges.cloudflare.com",
// Turnstile renders in an iframe hosted on challenges.cloudflare.com
"frame-src https://challenges.cloudflare.com",
"worker-src 'self' blob:",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
].join("; "),
},
];
import { SECURITY_HEADERS } from "./src/lib/security-edge";

const nextConfig: NextConfig = {
async headers() {
return [
{
// Covers every response, including static assets/paths that the
// middleware matcher excludes (see config.matcher in
// src/middleware.ts) - see SECURITY_HEADERS in
// src/lib/security-edge.ts for the single source of truth.
source: "/(.*)",
headers: securityHeaders,
headers: SECURITY_HEADERS as { key: string; value: string }[],
},
];
},
poweredByHeader: false,
};

export default nextConfig;
export default nextConfig;
81 changes: 81 additions & 0 deletions src/lib/__tests__/security-edge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { NextResponse } from "next/server";
import { applySecurityHeaders, SECURITY_HEADERS, isAllowedOrigin, ALLOWED_ORIGINS } from "@/lib/security-edge";

// Issue #368: security-edge.ts must set these on *every* response, not just
// some routes. next.config.ts's `headers()` also reuses SECURITY_HEADERS so
// the middleware-excluded static paths (see config.matcher in
// src/middleware.ts) get identical values instead of a second, drifted copy.
const REQUIRED_HEADERS = [
"Content-Security-Policy",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Strict-Transport-Security",
];

describe("SECURITY_HEADERS", () => {
it("includes all 5 headers required by issue #368", () => {
const keys = SECURITY_HEADERS.map((h) => h.key);
for (const required of REQUIRED_HEADERS) {
expect(keys).toContain(required);
}
});

it("has no duplicate keys", () => {
const keys = SECURITY_HEADERS.map((h) => h.key);
expect(new Set(keys).size).toBe(keys.length);
});

it("sets a restrictive default-src in the CSP", () => {
const csp = SECURITY_HEADERS.find((h) => h.key === "Content-Security-Policy");
expect(csp?.value).toContain("default-src 'self'");
});

it("uses the strict, non-framable X-Frame-Options value", () => {
const xfo = SECURITY_HEADERS.find((h) => h.key === "X-Frame-Options");
expect(xfo?.value).toBe("DENY");
});
});

describe("applySecurityHeaders", () => {
it("sets every required header on the response", () => {
const response = applySecurityHeaders(NextResponse.next());
for (const required of REQUIRED_HEADERS) {
expect(response.headers.get(required)).toBeTruthy();
}
});

it("applies every entry from SECURITY_HEADERS, not a hand-picked subset", () => {
const response = applySecurityHeaders(NextResponse.next());
for (const { key, value } of SECURITY_HEADERS) {
expect(response.headers.get(key)).toBe(value);
}
});

it("returns the same response instance it was given", () => {
const input = NextResponse.next();
expect(applySecurityHeaders(input)).toBe(input);
});
});

describe("isAllowedOrigin", () => {
it("allows same-origin/non-CORS requests (null origin)", () => {
expect(isAllowedOrigin(null)).toBe(true);
});

it("allows the production and Vercel preview domains", () => {
expect(isAllowedOrigin("https://remitx.app")).toBe(true);
expect(isAllowedOrigin("https://remitx.vercel.app")).toBe(true);
});

it("rejects an origin that isn't in the allowlist", () => {
expect(isAllowedOrigin("https://evil.example.com")).toBe(false);
});

it("keeps ALLOWED_ORIGINS and isAllowedOrigin in sync", () => {
for (const origin of ALLOWED_ORIGINS) {
expect(isAllowedOrigin(origin)).toBe(true);
}
});
});
52 changes: 45 additions & 7 deletions src/lib/security-edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,52 @@ import { NextResponse } from "next/server";
* imported by middleware (which runs in the Edge Runtime).
*/

// Single source of truth for the response security headers. `next.config.ts`
// imports this same list for its `headers()` config so that static assets
// excluded from the middleware matcher (see `config.matcher` in
// middleware.ts - _next/static, images, .css/.js, etc.) still get the exact
// same header values instead of a second, hand-copied set that can drift out
// of sync (previously next.config.ts had X-Frame-Options: SAMEORIGIN and
// Cross-Origin-Opener-Policy: same-origin-allow-popups while this file used
// stricter DENY / same-origin - two different policies for the same site
// depending on which path served the response).
export const SECURITY_HEADERS: ReadonlyArray<{ key: string; value: string }> = [
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
// Allow Turnstile + Next.js inline scripts (Next injects inline scripts)
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com",
// Allow Tailwind-injected styles + Google Fonts
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
// Allow Google Fonts woff2 + Turnstile font assets
"font-src 'self' https://fonts.gstatic.com data: https://challenges.cloudflare.com",
// Allow our images + Turnstile injected pixel/favicon
"img-src 'self' data: blob: https://challenges.cloudflare.com",
// Turnstile connects to challenges.cloudflare.com for widget + validation
"connect-src 'self' https://horizon-testnet.stellar.org https://api.stellar.org https://challenges.cloudflare.com",
// Turnstile renders in an iframe hosted on challenges.cloudflare.com
"frame-src https://challenges.cloudflare.com",
"worker-src 'self' blob:",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
].join("; "),
},
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "X-XSS-Protection", value: "1; mode=block" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
];

export function applySecurityHeaders(response: NextResponse): NextResponse {
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
response.headers.set("X-XSS-Protection", "1; mode=block");
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
for (const { key, value } of SECURITY_HEADERS) {
response.headers.set(key, value);
}
return response;
}

Expand Down
25 changes: 1 addition & 24 deletions src/lib/security.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
import crypto from "crypto";

/**
Expand Down Expand Up @@ -198,29 +198,6 @@ export function validateUpload(
return { valid: true };
}

// ── CORS lockdown ───────────────────────────────────────────────────────
export const ALLOWED_ORIGINS = new Set([
"https://remitx.app",
"http://localhost:3000",
"http://localhost:3001",
]);

export function isAllowedOrigin(origin: string | null): boolean {
if (!origin) return false;
return ALLOWED_ORIGINS.has(origin);
}

export function applySecurityHeaders(response: NextResponse): NextResponse {
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
response.headers.set("X-XSS-Protection", "1; mode=block");
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
return response;
}

// ── Password reset token ────────────────────────────────────────────────
export function generateResetToken(userId: string): { token: string; expiresAt: number } {
const expiresAt = Date.now() + 60 * 60 * 1000; // 1 hour
Expand Down