diff --git a/.cursor/rules/security.mdc b/.cursor/rules/security.mdc index 93d769b7d..58fa46908 100644 --- a/.cursor/rules/security.mdc +++ b/.cursor/rules/security.mdc @@ -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 diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 1074a3018..1ee3c0840 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -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. @@ -1712,4 +1708,154 @@ Here's a brief overview of the available namespaces: - **billing**: Subscription and payment [billing.json](mdc:apps/web/public/locales/en/billing.json) - **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. \ No newline at end of file +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 ; +} +``` + +### 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 \ No newline at end of file diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 6d62b5cf0..20e10574e 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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 ( - + {children} @@ -33,3 +36,9 @@ export default async function RootLayout({ ); } + +async function getCspNonce() { + const headersStore = await headers(); + + return headersStore.get('x-nonce') ?? undefined; +} diff --git a/apps/web/components/root-providers.tsx b/apps/web/components/root-providers.tsx index bc303a2b0..df246d8c7 100644 --- a/apps/web/components/root-providers.tsx +++ b/apps/web/components/root-providers.tsx @@ -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} diff --git a/apps/web/lib/create-csp-response.ts b/apps/web/lib/create-csp-response.ts new file mode 100644 index 000000000..397ab7e3d --- /dev/null +++ b/apps/web/lib/create-csp-response.ts @@ -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; +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 87e312e82..0101fed74 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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(); +} diff --git a/apps/web/package.json b/apps/web/package.json index 47353972c..2c6e44f04 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/package.json b/package.json index 1c78543a8..5c24f6ccb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-supabase-saas-kit-turbo", - "version": "2.7.1", + "version": "2.8.0", "private": true, "sideEffects": false, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 388f62667..2d20a02a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: