diff --git a/apps/app/middleware.ts b/apps/app/middleware.ts index e37a24e2..fab2b91e 100644 --- a/apps/app/middleware.ts +++ b/apps/app/middleware.ts @@ -1,6 +1,9 @@ import { authMiddleware } from '@repo/auth/middleware'; +import { noseconeConfig, noseconeMiddleware } from '@repo/security/middleware'; -export default authMiddleware(); +const securityHeaders = noseconeMiddleware(noseconeConfig); + +export default authMiddleware(() => securityHeaders()); export const config = { matcher: [ diff --git a/apps/app/package.json b/apps/app/package.json index 0c9b256b..4c722372 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -14,8 +14,8 @@ }, "dependencies": { "@prisma/client": "6.0.1", - "@repo/auth": "workspace:*", "@repo/analytics": "workspace:*", + "@repo/auth": "workspace:*", "@repo/collaboration": "workspace:*", "@repo/database": "workspace:*", "@repo/design-system": "workspace:*", diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 5d1d703f..f3f41be6 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -2,6 +2,7 @@ import { authMiddleware } from '@repo/auth/middleware'; import { env } from '@repo/env'; import { parseError } from '@repo/observability/error'; import { secure } from '@repo/security'; +import { noseconeConfig, noseconeMiddleware } from '@repo/security/middleware'; import { NextResponse } from 'next/server'; export const config = { @@ -10,9 +11,11 @@ export const config = { matcher: ['/((?!_next/static|_next/image|ingest|favicon.ico).*)'], }; +const securityHeaders = noseconeMiddleware(noseconeConfig); + export default authMiddleware(async (_auth, request) => { if (!env.ARCJET_KEY) { - return NextResponse.next(); + return securityHeaders(); } try { @@ -26,7 +29,7 @@ export default authMiddleware(async (_auth, request) => { request ); - return NextResponse.next(); + return securityHeaders(); } catch (error) { const message = parseError(error); diff --git a/docs/features/security/headers.mdx b/docs/features/security/headers.mdx index b6346479..a1f3618b 100644 --- a/docs/features/security/headers.mdx +++ b/docs/features/security/headers.mdx @@ -3,25 +3,106 @@ title: Security Headers description: Security headers used to protect your application. --- -next-forge uses [next-secure-headers](https://github.com/jagaapple/next-secure-headers) to set HTTP response headers related to security. +import { Authors } from '/snippets/authors.mdx'; + + + +next-forge uses [Nosecone](https://docs.arcjet.com/nosecone/quick-start) to set HTTP response headers related to security. ## Configuration Here are the headers we have enabled: -| Property | Header | Description | Value | -| --- | --- | --- | --- | -| `forceHTTPSRedirect` | `Strict-Transport-Security` | Prevents browsers from connecting to your site over HTTP. | `[true, { maxAge: 63_072_000, includeSubDomains: true, preload: true }]` | -| `frameGuard` | `X-Frame-Options` | Prevents browsers from rendering your site in an iframe. | `deny` | -| `noopen` | `X-Download-Options` | Prevents browsers from automatically opening downloaded files in the same origin as the page. | `noopen` | -| `nosniff` | `X-Content-Type-Options` | Prevents browsers from MIME-sniffing a response away from the declared content type. | `nosniff` | -| `xssProtection` | `X-XSS-Protection` | Prevents browsers from executing inline scripts if a cross-site scripting attack is detected. | `sanitize` | -| `contentSecurityPolicy` | `Content-Security-Policy` | Sets a policy to prevent a wide range of different types of attacks, including Cross Site Scripting (XSS) and data injection attacks. | `false` | -| `expectCT` | `Expect-CT` | Enables a mechanism to mitigate the risk of fraudulent certificates being used in connections to your site. | `false` | -| `referrerPolicy` | `Referrer-Policy` | Controls how much of the full URL is included in the `Referer` header. | `false` | +- `Cross-Origin-Embedder-Policy` (COEP) +- `Cross-Origin-Opener-Policy` +- `Cross-Origin-Resource-Policy` +- `Origin-Agent-Cluster` +- `Referrer-Policy` +- `Strict-Transport-Security` (HSTS) +- `X-Content-Type-Options` +- `X-DNS-Prefetch-Control` +- `X-Download-Options` +- `X-Frame-Options` +- `X-Permitted-Cross-Domain-Policies` +- `X-XSS-Protection` -The `forceHTTPSRedirect` property has been customized from the default to include subdomains and preload the HSTS policy. This should allow you to submit your site at [hstspreload.org](https://hstspreload.org/) without any issues. +See the [Nosecone reference](https://docs.arcjet.com/nosecone/reference) for details on each header and configuration options. ## Usage -The headers are enabled by default when using the `next-config` package. If you are customizing your `next.config.ts` file, you can extend the headers manually. +Recommended headers are set by default and configured in `@repo/security/middleware`. Changing the configuration here will affect all apps. + +They are then attached to the response within the middleware in `apps/app/middleware` and `apps/web/middleware.ts`. Adjusting the configuration in these files will only affect the specific app. + +## Content Security Policy (CSP) + +The CSP header is not set by default because it requires specific configuration based on the next-forge features you have enabled. + +In the meantime, you can set the CSP header using the Nosecone configuration. For example, the following CSP configuration will work with the default next-forge features: + +```ts +import type { NoseconeOptions } from '@nosecone/next'; +import { defaults as noseconeDefaults } from '@nosecone/next'; + +const noseconeOptions: NoseconeOptions = { + ...noseconeDefaults, + contentSecurityPolicy: { + ...noseconeDefaults.contentSecurityPolicy, + directives: { + ...noseconeDefaults.contentSecurityPolicy.directives, + scriptSrc: [ + // We have to use unsafe-inline because next-themes and Vercel Analytics + // do not support nonce + // https://github.com/pacocoursey/next-themes/issues/106 + // https://github.com/vercel/analytics/issues/122 + //...noseconeDefaults.contentSecurityPolicy.directives.scriptSrc, + "'self'", + "'unsafe-inline'", + "https://www.googletagmanager.com", + "https://*.clerk.accounts.dev", + "https://va.vercel-scripts.com", + ], + connectSrc: [ + ...noseconeDefaults.contentSecurityPolicy.directives.connectSrc, + "https://*.clerk.accounts.dev", + "https://*.google-analytics.com", + "https://clerk-telemetry.com", + ], + workerSrc: [ + ...noseconeDefaults.contentSecurityPolicy.directives.workerSrc, + "blob:", + "https://*.clerk.accounts.dev" + ], + imgSrc: [ + ...noseconeDefaults.contentSecurityPolicy.directives.imgSrc, + "https://img.clerk.com" + ], + objectSrc: [ + ...noseconeDefaults.contentSecurityPolicy.directives.objectSrc, + ], + // We only set this in production because the server may be started + // without HTTPS + upgradeInsecureRequests: process.env.NODE_ENV === "production", + }, + }, +} +``` + diff --git a/packages/next-config/index.ts b/packages/next-config/index.ts index 71214f21..b83f7dd9 100644 --- a/packages/next-config/index.ts +++ b/packages/next-config/index.ts @@ -6,7 +6,6 @@ import { env } from '@repo/env'; import { withSentryConfig } from '@sentry/nextjs'; import withVercelToolbar from '@vercel/toolbar/plugins/next'; import type { NextConfig } from 'next'; -import { createSecureHeaders } from 'next-secure-headers'; const otelRegex = /@opentelemetry\/instrumentation/; @@ -39,22 +38,6 @@ const baseConfig: NextConfig = { ]; }, - // biome-ignore lint/suspicious/useAwait: headers is async - async headers() { - return [ - { - source: '/(.*)', - headers: createSecureHeaders({ - // HSTS Preload: https://hstspreload.org/ - forceHTTPSRedirect: [ - true, - { maxAge: 63_072_000, includeSubDomains: true, preload: true }, - ], - }), - }, - ]; - }, - webpack(config, { isServer }) { if (isServer) { config.plugins = [...config.plugins, new PrismaPlugin()]; diff --git a/packages/next-config/package.json b/packages/next-config/package.json index cd4b4689..13bb1050 100644 --- a/packages/next-config/package.json +++ b/packages/next-config/package.json @@ -18,7 +18,6 @@ "@next/bundle-analyzer": "^15.1.0", "@prisma/nextjs-monorepo-workaround-plugin": "^6.0.1", "@sentry/nextjs": "^8.43.0", - "@vercel/toolbar": "^0.1.28", - "next-secure-headers": "^2.2.0" + "@vercel/toolbar": "^0.1.28" } } diff --git a/packages/security/middleware.ts b/packages/security/middleware.ts new file mode 100644 index 00000000..d32625e9 --- /dev/null +++ b/packages/security/middleware.ts @@ -0,0 +1,23 @@ +import { + type NoseconeOptions, + defaults, + withVercelToolbar, +} from '@nosecone/next'; +import { env } from '@repo/env'; +export { createMiddleware as noseconeMiddleware } from '@nosecone/next'; + +// Nosecone security headers configuration +// https://docs.arcjet.com/nosecone/quick-start +const noseconeOptions: NoseconeOptions = { + ...defaults, + // Content Security Policy (CSP) is disabled by default because the values + // depend on which Next Forge features are enabled. See + // https://docs.next-forge.com/features/security/headers for guidance on how + // to configure it. + contentSecurityPolicy: false, +}; + +export const noseconeConfig: NoseconeOptions = + env.NODE_ENV === 'development' && env.FLAGS_SECRET + ? withVercelToolbar(noseconeOptions) + : noseconeOptions; diff --git a/packages/security/package.json b/packages/security/package.json index b551a6a9..4a9cdc2c 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -2,6 +2,7 @@ "name": "@repo/security", "version": "0.0.0", "private": true, + "type": "module", "scripts": { "clean": "git clean -xdf .cache .turbo dist node_modules", "format": "biome lint --write .", @@ -10,6 +11,7 @@ }, "dependencies": { "@arcjet/next": "1.0.0-alpha.34", + "@nosecone/next": "1.0.0-alpha.34", "@repo/env": "workspace:*" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 153f7e5c..614fc6f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -918,9 +918,6 @@ importers: '@vercel/toolbar': specifier: ^0.1.28 version: 0.1.28(next@15.1.0(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(vite@5.4.10(@types/node@22.10.1)(terser@5.31.0)) - next-secure-headers: - specifier: ^2.2.0 - version: 2.2.0 devDependencies: '@repo/env': specifier: workspace:* @@ -987,6 +984,9 @@ importers: '@arcjet/next': specifier: 1.0.0-alpha.34 version: 1.0.0-alpha.34(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.6.1(@bufbuild/protobuf@1.10.0))(next@15.1.0(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) + '@nosecone/next': + specifier: 1.0.0-alpha.34 + version: 1.0.0-alpha.34(next@15.1.0(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) '@repo/env': specifier: workspace:* version: link:../env @@ -2844,6 +2844,12 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@nosecone/next@1.0.0-alpha.34': + resolution: {integrity: sha512-4KXgjR580QTsdbM/F9WoLR/AQxJD48na27XnS9De5LIFBemDZ5ObC6L901WrBF3EJGz1M0E9AFQ0nKGUh+ascQ==} + engines: {node: '>=18'} + peerDependencies: + next: '>=14' + '@octokit/auth-token@2.5.0': resolution: {integrity: sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==} @@ -7813,10 +7819,6 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} - next-secure-headers@2.2.0: - resolution: {integrity: sha512-C7OfZ9JdSJyYMz2ZBMI/WwNbt0qNjlFWX9afUp8nEUzbz6ez3JbeopdyxSZJZJAzVLIAfyk6n73rFpd4e22jRg==} - engines: {node: '>=10.0.0'} - next-themes@0.4.4: resolution: {integrity: sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==} peerDependencies: @@ -7957,6 +7959,10 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} + nosecone@1.0.0-alpha.34: + resolution: {integrity: sha512-GPqypMOeGXjZXHS+I9jTBFB12GNlAmC21LRRhAKuc4v2beMDzW/ChYSQ8sMSr0f7/Ov4ffQJH0i84iAny5Y5fg==} + engines: {node: '>=18'} + npm-run-path@2.0.2: resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} engines: {node: '>=4'} @@ -12125,6 +12131,11 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@nosecone/next@1.0.0-alpha.34(next@15.1.0(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))': + dependencies: + next: 15.1.0(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + nosecone: 1.0.0-alpha.34 + '@octokit/auth-token@2.5.0': dependencies: '@octokit/types': 6.41.0 @@ -18170,8 +18181,6 @@ snapshots: netmask@2.0.2: {} - next-secure-headers@2.2.0: {} - next-themes@0.4.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: react: 19.0.0 @@ -18367,6 +18376,8 @@ snapshots: normalize-range@0.1.2: {} + nosecone@1.0.0-alpha.34: {} + npm-run-path@2.0.2: dependencies: path-key: 2.0.1