Add Strict CSP Headers (#243)

* Add CSP nonce support and enhance security headers

Introduced secure headers and CSP nonce to improve app security by integrating `@nosecone/next`. Updated middleware, root providers, and layout to handle nonce propagation, enabling stricter CSP policies when configured. Also upgraded dependencies and tooling versions.

* Add OTP and security guidelines documentation and additional checks on client-provided values

- Introduced additional checks on client-provided values such as cookies
- Introduced a new OTP API documentation outlining the creation and verification of OTP tokens for sensitive operations.
- Added comprehensive security guidelines for writing secure code in Next.js, covering client and server components, environment variables, authentication, and error handling.

These additions enhance the project's security posture and provide clear instructions for developers on implementing secure practices.
This commit is contained in:
Giancarlo Buomprisco
2025-04-22 09:43:21 +07:00
committed by GitHub
parent 903ef6dc08
commit db9ddab6ad
9 changed files with 312 additions and 15 deletions

View File

@@ -126,7 +126,7 @@ Consider using OTP tokens when implementing highly destructive operations like d
### Personal Information
- Never log or expose sensitive user data
- Never log or expose sensitive user data (api keys, passwords, secrets, etc.)
- Use proper session management
## Error Handling

View File

@@ -1424,7 +1424,6 @@ You always must use `(security_invoker = true)` for views.
SELECT public.is_set('enable_team_accounts');
```
# Server Actions
- For Data Mutations from Client Components, always use Server Actions
@@ -1458,7 +1457,6 @@ export const myServerAction = enhanceAction(
);
```
# Route Handler / API Routes
- Use Route Handlers when data fetching from Client Components
@@ -1505,8 +1503,6 @@ export const GET = enhanceRouteHandler(
);
```
# Access Control & Permissions Guidelines
This rule provides guidance for implementing access control, permissions, and subscription-related functionality in the application.
@@ -1713,3 +1709,153 @@ Here's a brief overview of the available namespaces:
- **marketing**: Landing pages, blog, etc. [marketing.json](mdc:apps/web/public/locales/en/marketing.json)
When creating a new functionality, it can be useful to add a new namespace.
# Security Best Practices
## Next.js-Specific Security
### Client Component Data Passing
- **Never pass sensitive data** to Client Components
- **Never pass unsanitized data** to Client Components (raw cookies, client-provided data)
### Server Components Security
- **Always sanitize user input** before using in Server Components
- **Validate cookies and headers** in Server Components
### Environment Variables
- **Use `import 'server-only'`** for code that should only be run on the server side
- **Never expose server-only env vars** to the client
- **Never pass environment variables as props to client components** unless they're suffixed with `NEXT_PUBLIC_`
- **Never use `NEXT_PUBLIC_` prefix** for sensitive data (ex. API keys, secrets)
- **Use `NEXT_PUBLIC_` prefix** only for client-safe variables
### Client Hydration Protection
- **Never expose sensitive data** in initial HTML
## Authentication & Authorization
### Row Level Security (RLS)
- **Always enable RLS** on all tables unless explicitly specified otherwise [database.mdc](mdc:.cursor/rules/database.mdc)
### Super Admin Protected Routes
Always perform extra checks when writing Super Admin code [super-admin.mdc](mdc:.cursor/rules/super-admin.mdc)
## Server Actions & API Routes
### Server Actions
- Always use `enhanceAction` wrapper for consistent security [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc)
- Always use 'use server' directive at the top of the file to safely bundle server-side code
- Validate input with Zod schemas
- Implement authentication checks:
```typescript
'use server';
import { enhanceAction } from '@kit/next/actions';
import { MyActionSchema } from '../schema';
export const secureAction = enhanceAction(
async function(data, user) {
// Additional permission checks
const hasPermission = await checkUserPermission(user.id, data.accountId, 'action.perform');
if (!hasPermission) throw new Error('Insufficient permissions');
// Validated data available
return processAction(data);
},
{
auth: true,
schema: MyActionSchema
}
);
```
### API Routes
- Use `enhanceRouteHandler` for consistent security [route-handlers.mdc](mdc:.cursor/rules/route-handlers.mdc)
- Implement authentication and authorization checks:
```typescript
import { enhanceRouteHandler } from '@kit/next/routes';
import { RouteSchema } from '../schema';
export const POST = enhanceRouteHandler(
async function({ body, user, request }) {
// Additional authorization checks
const canAccess = await canAccessResource(user.id, body.resourceId);
if (!canAccess) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// Safe to process with validated body
return NextResponse.json({ success: true });
},
{
auth: true,
schema: RouteSchema
}
);
```
## Client Components Security
### Context Awareness
- Use appropriate workspace contexts for access control:
- `useUserWorkspace()` in personal account pages
- `useTeamAccountWorkspace()` in team account pages
- Check permissions before rendering sensitive UI elements:
```typescript
function SecureComponent() {
const { account, user } = useTeamAccountWorkspace();
const canEdit = account.permissions.includes('entity.update');
if (!canEdit) return null;
return <EditComponent />;
}
```
### Data Fetching
- Use React Query with proper error handling
- Never trust client-side permission checks alone
## One-Time Tokens
Consider using OTP tokens when implementing highly destructive operations like deleting an entity that would otherwise require a full backup: [otp.mdc](mdc:.cursor/rules/otp.mdc)
## Critical Data Protection
### Personal Information
- Never log or expose sensitive user data
- Use proper session management
## Error Handling
- Never expose internal errors to clients
- Log errors securely with appropriate context
- Return generic error messages to users
```typescript
try {
await sensitiveOperation();
} catch (error) {
logger.error({ error, context }, 'Operation failed');
return { error: 'Unable to complete operation' };
}
```
## Database Security
- Avoid dynamic SQL generation
- Use SECURITY DEFINER functions sparingly and carefully, warn user if you do so
- Implement proper foreign key constraints
- Use appropriate data types with constraints

View File

@@ -1,3 +1,5 @@
import { headers } from 'next/headers';
import { Toaster } from '@kit/ui/sonner';
import { RootProviders } from '~/components/root-providers';
@@ -20,11 +22,12 @@ export default async function RootLayout({
const { language } = await createI18nServerInstance();
const theme = await getRootTheme();
const className = getFontsClassName(theme);
const nonce = await getCspNonce();
return (
<html lang={language} className={className}>
<body>
<RootProviders theme={theme} lang={language}>
<RootProviders theme={theme} lang={language} nonce={nonce}>
{children}
</RootProviders>
@@ -33,3 +36,9 @@ export default async function RootLayout({
</html>
);
}
async function getCspNonce() {
const headersStore = await headers();
return headersStore.get('x-nonce') ?? undefined;
}

View File

@@ -37,14 +37,21 @@ const CaptchaTokenSetter = dynamic(async () => {
};
});
type RootProvidersProps = React.PropsWithChildren<{
// The language to use for the app (optional)
lang?: string;
// The theme (light or dark or system) (optional)
theme?: string;
// The CSP nonce to pass to scripts (optional)
nonce?: string;
}>;
export function RootProviders({
lang,
theme = appConfig.theme,
nonce,
children,
}: React.PropsWithChildren<{
lang?: string;
theme?: string;
}>) {
}: RootProvidersProps) {
const i18nSettings = useMemo(() => getI18nSettings(lang), [lang]);
return (
@@ -63,6 +70,7 @@ export function RootProviders({
disableTransitionOnChange
defaultTheme={theme}
enableColorScheme={false}
nonce={nonce}
>
{children}
</ThemeProvider>

View File

@@ -0,0 +1,94 @@
import type { NoseconeOptions } from '@nosecone/next';
// we need to allow connecting to the Supabase API from the client
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL as string;
// the URL used for Supabase Realtime
const WEBSOCKET_URL = SUPABASE_URL.replace('https://', 'ws://').replace(
'http://',
'ws://',
);
// disabled to allow loading images from Supabase Storage
const CROSS_ORIGIN_EMBEDDER_POLICY = false;
/**
* @name ALLOWED_ORIGINS
* @description List of allowed origins for the "connectSrc" directive in the Content Security Policy.
*/
const ALLOWED_ORIGINS = [
SUPABASE_URL,
WEBSOCKET_URL,
// add here additional allowed origins
] as never[];
/**
* @name IMG_SRC_ORIGINS
*/
const IMG_SRC_ORIGINS = [SUPABASE_URL] as never[];
/**
* @name UPGRADE_INSECURE_REQUESTS
* @description Upgrade insecure requests to HTTPS when in production
*/
const UPGRADE_INSECURE_REQUESTS = process.env.NODE_ENV === 'production';
/**
* @name createCspResponse
* @description Create a middleware with enhanced headers applied (if applied).
*/
export async function createCspResponse() {
const {
createMiddleware,
withVercelToolbar,
defaults: noseconeConfig,
} = await import('@nosecone/next');
/*
* @name allowedOrigins
* @description List of allowed origins for the "connectSrc" directive in the Content Security Policy.
*/
const config: NoseconeOptions = {
...noseconeConfig,
contentSecurityPolicy: {
directives: {
...noseconeConfig.contentSecurityPolicy.directives,
connectSrc: [
...noseconeConfig.contentSecurityPolicy.directives.connectSrc,
...ALLOWED_ORIGINS,
],
imgSrc: [
...noseconeConfig.contentSecurityPolicy.directives.imgSrc,
...IMG_SRC_ORIGINS,
],
upgradeInsecureRequests: UPGRADE_INSECURE_REQUESTS,
},
},
crossOriginEmbedderPolicy: CROSS_ORIGIN_EMBEDDER_POLICY,
};
const middleware = createMiddleware(
process.env.VERCEL_ENV === 'preview' ? withVercelToolbar(config) : config,
);
// create response
const response = await middleware();
if (response) {
const contentSecurityPolicy = response.headers.get(
'Content-Security-Policy',
);
const matches = contentSecurityPolicy?.match(/nonce-([\w-]+)/) || [];
const nonce = matches[1];
// set x-nonce header if nonce is found
// so we can pass it to client-side scripts
if (nonce) {
response.headers.set('x-nonce', nonce);
}
}
return response;
}

View File

@@ -24,7 +24,8 @@ const getUser = (request: NextRequest, response: NextResponse) => {
};
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
const secureHeaders = await createResponseWithSecureHeaders();
const response = NextResponse.next(secureHeaders);
// set a unique request ID for each request
// this helps us log and trace requests
@@ -59,7 +60,7 @@ export async function middleware(request: NextRequest) {
async function withCsrfMiddleware(
request: NextRequest,
response = new NextResponse(),
response: NextResponse,
) {
// set up CSRF protection
const csrfProtect = createCsrfProtect({
@@ -100,7 +101,7 @@ async function adminMiddleware(request: NextRequest, response: NextResponse) {
const isAdminPath = request.nextUrl.pathname.startsWith('/admin');
if (!isAdminPath) {
return response;
return;
}
const {
@@ -222,3 +223,21 @@ function matchUrlPattern(url: string) {
function setRequestId(request: Request) {
request.headers.set('x-correlation-id', crypto.randomUUID());
}
/**
* @name createResponseWithSecureHeaders
* @description Create a middleware with enhanced headers applied (if applied).
* This is disabled by default. To enable set ENABLE_STRICT_CSP=true
*/
async function createResponseWithSecureHeaders() {
const enableStrictCsp = process.env.ENABLE_STRICT_CSP ?? 'false';
// we disable ENABLE_STRICT_CSP by default
if (enableStrictCsp === 'false') {
return {};
}
const { createCspResponse } = await import('./lib/create-csp-response');
return createCspResponse();
}

View File

@@ -54,6 +54,7 @@
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@marsidev/react-turnstile": "^1.1.0",
"@nosecone/next": "1.0.0-beta.6",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.49.4",
"@tanstack/react-query": "5.74.4",

View File

@@ -1,6 +1,6 @@
{
"name": "next-supabase-saas-kit-turbo",
"version": "2.7.1",
"version": "2.8.0",
"private": true,
"sideEffects": false,
"engines": {

20
pnpm-lock.yaml generated
View File

@@ -196,6 +196,9 @@ importers:
'@marsidev/react-turnstile':
specifier: ^1.1.0
version: 1.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@nosecone/next':
specifier: 1.0.0-beta.6
version: 1.0.0-beta.6(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2(react@19.1.0)
@@ -2172,6 +2175,12 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@nosecone/next@1.0.0-beta.6':
resolution: {integrity: sha512-WIda5bNyUdm5M653IiPhdFuYz7to4/b/sdgZtldTrTihbiJQEq9KbpMvtAKvPSTeqn6W4y3Io/dMF5bBQie9Fw==}
engines: {node: '>=18'}
peerDependencies:
next: '>=14'
'@opentelemetry/api-logs@0.50.0':
resolution: {integrity: sha512-JdZuKrhOYggqOpUljAq4WWNi5nB10PmgoF0y2CvedLGXd0kSawb/UBnWT8gg1ND3bHCNHStAIVT0ELlxJJRqrA==}
engines: {node: '>=14'}
@@ -6710,6 +6719,10 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
nosecone@1.0.0-beta.6:
resolution: {integrity: sha512-h+cxBMOuv8VaRwBAt9/W9+PJfgi4N7EcjHwxwl1Rf0d9cFen1zphg4qooydUnZ+9ezeBzg64xMcWFROLy4QKDg==}
engines: {node: '>=18'}
npm-normalize-package-bin@4.0.0:
resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -9099,6 +9112,11 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@nosecone/next@1.0.0-beta.6(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))':
dependencies:
next: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
nosecone: 1.0.0-beta.6
'@opentelemetry/api-logs@0.50.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -14620,6 +14638,8 @@ snapshots:
normalize-path@3.0.0: {}
nosecone@1.0.0-beta.6: {}
npm-normalize-package-bin@4.0.0: {}
npm-run-path@4.0.1: