diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 59adaf914..cf373e62b 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -15,6 +15,8 @@ import { getEnv, init } from './utils/env.server.ts' import { getInstanceInfo } from './utils/litefs.server.ts' import { NonceProvider } from './utils/nonce-provider.ts' import { makeTimings } from './utils/timing.server.ts' +import { combineHeaders } from './utils/misc.tsx' +import { helmet } from './utils/helmet.server.ts' export const streamTimeout = 5000 @@ -65,9 +67,14 @@ export default async function handleRequest(...args: DocRequestArgs) { const body = new PassThrough() responseHeaders.set('Content-Type', 'text/html') responseHeaders.append('Server-Timing', timings.toString()) + + const headers = combineHeaders( + responseHeaders, + helmet({ html: true, nonce }), + ) resolve( new Response(createReadableStreamFromReadable(body), { - headers: responseHeaders, + headers, status: didError ? 500 : responseStatusCode, }), ) diff --git a/app/routes/resources+/note-images.$imageId.tsx b/app/routes/resources+/note-images.$imageId.tsx index 52c32fb18..cfb479b5b 100644 --- a/app/routes/resources+/note-images.$imageId.tsx +++ b/app/routes/resources+/note-images.$imageId.tsx @@ -1,5 +1,7 @@ import { invariantResponse } from '@epic-web/invariant' import { prisma } from '#app/utils/db.server.ts' +import { helmet } from '#app/utils/helmet.server.ts' +import { combineHeaders } from '#app/utils/misc.tsx' import { type Route } from './+types/note-images.$imageId.ts' export async function loader({ params }: Route.LoaderArgs) { @@ -12,11 +14,14 @@ export async function loader({ params }: Route.LoaderArgs) { invariantResponse(image, 'Not found', { status: 404 }) return new Response(image.blob, { - headers: { - 'Content-Type': image.contentType, - 'Content-Length': Buffer.byteLength(image.blob).toString(), - 'Content-Disposition': `inline; filename="${params.imageId}"`, - 'Cache-Control': 'public, max-age=31536000, immutable', - }, + headers: combineHeaders( + { + 'Content-Type': image.contentType, + 'Content-Length': Buffer.byteLength(image.blob).toString(), + 'Content-Disposition': `inline; filename="${params.imageId}"`, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + helmet(), + ), }) } diff --git a/app/utils/helmet.server.ts b/app/utils/helmet.server.ts new file mode 100644 index 000000000..a095e983c --- /dev/null +++ b/app/utils/helmet.server.ts @@ -0,0 +1,46 @@ +import { helmet as _helmet } from './helmet' + +const MODE = process.env.NODE_ENV ?? 'development' + +export function helmet(options: { + html: true + nonce: string + cors?: boolean +}): Headers +export function helmet(options?: { html?: false; cors?: boolean }): Headers +export function helmet({ + html = false, + cors = false, + nonce, +}: { + html?: boolean + cors?: boolean + nonce?: string +} = {}) { + return _helmet({ + html, + cors, + options: { + referrerPolicy: ['same-origin'], + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: { + // NOTE: Remove reportOnly when you're ready to enforce this CSP + reportOnly: true, + directives: { + fetch: { + 'connect-src': [ + MODE === 'development' ? 'ws:' : undefined, + process.env.SENTRY_DSN ? '*.sentry.io' : undefined, + "'self'", + ], + 'font-src': ["'self'"], + 'frame-src': ["'self'"], + 'img-src': ["'self'", 'data:'], + 'script-src': ["'strict-dynamic'", "'self'", `'nonce-${nonce}'`], + 'script-src-attr': [`'nonce-${nonce}'`], + }, + }, + }, + }, + }) +} diff --git a/app/utils/helmet/index.ts b/app/utils/helmet/index.ts new file mode 100644 index 000000000..c593a3c9f --- /dev/null +++ b/app/utils/helmet/index.ts @@ -0,0 +1,202 @@ +import { combineHeaders } from '../misc.tsx' +import { + contentSecurityPolicy, + type ContentSecurityPolicyOptions, + crossOriginEmbedderPolicy, + type CrossOriginEmbedderPolicyOptions, + crossOriginOpenerPolicy, + type CrossOriginOpenerPolicyOptions, + crossOriginResourcePolicy, + type CrossOriginResourcePolicyOptions, + referrerPolicy, + type ReferrerPolicyOptions, + strictTransportSecurity, + type StrictTransportSecurityOptions, + xDnsPrefetchControl, + type XDnsPrefetchControlOptions, + xFrameOptions, + type XFrameOptionsOptions, + xPermittedCrossDomainPolicies, + type XPermittedCrossDomainPoliciesOptions, + xXssProtection, + originAgentCluster, + xContentTypeOptions, + xDownloadOptions, +} from './middlewares/index.ts' + +export function helmet({ + options, + html = true, + cors = false, +}: HelmetOptions): Headers { + let headers = new Headers() + switch (options.crossOriginResourcePolicy) { + case undefined: + case true: + headers = combineHeaders( + headers, + crossOriginResourcePolicy(cors ? 'cross-origin' : 'same-origin'), + ) + break + case false: + break + default: + headers = combineHeaders( + headers, + crossOriginResourcePolicy(options.crossOriginResourcePolicy), + ) + } + + switch (options.crossOriginEmbedderPolicy) { + case undefined: + case true: + headers = combineHeaders(headers, crossOriginEmbedderPolicy()) + break + case false: + break + default: + headers = combineHeaders( + headers, + crossOriginEmbedderPolicy(options.crossOriginEmbedderPolicy), + ) + } + + if (options.originAgentCluster ?? true) { + headers = combineHeaders(headers, originAgentCluster()) + } + + switch (options.referrerPolicy) { + case undefined: + case true: + headers = combineHeaders(headers, referrerPolicy()) + break + case false: + break + default: + headers = combineHeaders(headers, referrerPolicy(options.referrerPolicy)) + } + + switch (options.strictTransportSecurity) { + case undefined: + case true: + headers = combineHeaders(headers, strictTransportSecurity()) + break + case false: + break + default: + headers = combineHeaders( + headers, + strictTransportSecurity(options.strictTransportSecurity), + ) + } + + if (options.xContentTypeOptions ?? true) { + headers = combineHeaders(headers, xContentTypeOptions()) + } + + switch (options.xDnsPrefetchControl) { + case undefined: + case true: + headers = combineHeaders(headers, xDnsPrefetchControl()) + break + case false: + break + default: + headers = combineHeaders( + headers, + xDnsPrefetchControl(options.xDnsPrefetchControl), + ) + } + + switch (options.xPermittedCrossDomainPolicies) { + case undefined: + case true: + headers = combineHeaders(headers, xPermittedCrossDomainPolicies()) + break + case false: + break + default: + headers = combineHeaders( + headers, + xPermittedCrossDomainPolicies(options.xPermittedCrossDomainPolicies), + ) + } + + if (options.xXssProtection ?? true) { + headers = combineHeaders(headers, xXssProtection()) + } + + if (html) { + switch (options.contentSecurityPolicy) { + case undefined: + case true: + headers = combineHeaders(headers, contentSecurityPolicy()) + break + case false: + break + default: + headers = combineHeaders( + headers, + contentSecurityPolicy(options.contentSecurityPolicy), + ) + } + + switch (options.crossOriginOpenerPolicy) { + case undefined: + case true: + headers = combineHeaders(headers, crossOriginOpenerPolicy()) + break + case false: + break + default: + headers = combineHeaders( + headers, + crossOriginOpenerPolicy(options.crossOriginOpenerPolicy), + ) + } + + if (options.xDownloadOptions ?? true) { + headers = combineHeaders(headers, xDownloadOptions()) + } + + switch (options.xFrameOptions) { + case undefined: + case true: + headers = combineHeaders(headers, xFrameOptions()) + break + case false: + break + default: + headers = combineHeaders(headers, xFrameOptions(options.xFrameOptions)) + } + } + + return headers +} + +export type HelmetOptions = { + options: SecureOptions + html?: boolean + cors?: boolean +} + +export type SecureOptions = GeneralSecureOptions & HtmlSpecificSecureOptions + +type GeneralSecureOptions = { + crossOriginResourcePolicy?: CrossOriginResourcePolicyOptions | boolean + crossOriginEmbedderPolicy?: CrossOriginEmbedderPolicyOptions | boolean + originAgentCluster?: boolean + referrerPolicy?: ReferrerPolicyOptions | boolean + strictTransportSecurity?: StrictTransportSecurityOptions | boolean + xContentTypeOptions?: boolean + xDnsPrefetchControl?: XDnsPrefetchControlOptions | boolean + xPermittedCrossDomainPolicies?: XPermittedCrossDomainPoliciesOptions | boolean + xXssProtection?: boolean +} + +type HtmlSpecificSecureOptions = { + contentSecurityPolicy?: ContentSecurityPolicyOptions | boolean + crossOriginOpenerPolicy?: CrossOriginOpenerPolicyOptions | boolean + xDownloadOptions?: boolean + xFrameOptions?: XFrameOptionsOptions | boolean +} diff --git a/app/utils/helmet/middlewares/content-security-policy.ts b/app/utils/helmet/middlewares/content-security-policy.ts new file mode 100644 index 000000000..7b892de6b --- /dev/null +++ b/app/utils/helmet/middlewares/content-security-policy.ts @@ -0,0 +1,275 @@ +import { type SmartString } from '#app/utils/misc.ts' + +export const contentSecurityPolicy = ( + options?: ContentSecurityPolicyOptions, +): Headers => { + const headers = new Headers() + const { useDefaults = true, reportOnly = false, directives } = options ?? {} + const rawDirectives = useDefaults + ? defaultizeDirectives(directives) + : directives + + if (!rawDirectives) { + return headers + } + + const policy: string[] = [] + if (rawDirectives.fetch) { + for (const [directive, value] of Object.entries(rawDirectives.fetch)) { + policy.push( + `${directive} ${value === "'none'" ? value : value.filter(Boolean).join(' ')}`, + ) + } + } + + if (rawDirectives.document) { + const { ['base-uri']: baseUri, sandbox } = rawDirectives.document + if (baseUri) { + policy.push( + `base-uri ${baseUri === "'none'" ? baseUri : baseUri.filter(Boolean).join(' ')}`, + ) + } + if (sandbox) { + policy.push(`sandbox ${sandbox}`) + } + } + + if (rawDirectives.navigation) { + const { ['form-action']: formAction, ['frame-ancestors']: frameAncestors } = + rawDirectives.navigation + + if (formAction) { + policy.push( + `form-action ${formAction === "'none'" ? formAction : formAction.filter(Boolean).join(' ')}`, + ) + } + if (frameAncestors) { + policy.push( + `frame-ancestors ${frameAncestors === "'none'" ? frameAncestors : frameAncestors.filter(Boolean).join(' ')}`, + ) + } + } + + if (rawDirectives.reporting) { + const { ['report-to']: reportTo } = rawDirectives.reporting + if (reportTo) { + policy.push(`report-to ${reportTo}`) + } + } + + if (rawDirectives.other) { + const { + ['require-trusted-types-for']: requireTrustedTypesFor, + ['trusted-types']: trustedTypes, + ['upgrade-insecure-requests']: upgradeInsecureRequests, + } = rawDirectives.other + + if (requireTrustedTypesFor) { + policy.push(`require-trusted-types-for ${requireTrustedTypesFor}`) + } + + if (trustedTypes) { + policy.push( + `trusted-types ${trustedTypes === "'none'" ? trustedTypes : trustedTypes.filter(Boolean).join(' ')}`, + ) + } + + if (upgradeInsecureRequests) { + policy.push('upgrade-insecure-requests') + } + } + + if (rawDirectives.deprecated) { + const { + ['block-all-mixed-content']: blockAllMixedContent, + ['report-uri']: reportUri, + } = rawDirectives.deprecated + + if (blockAllMixedContent) { + policy.push('block-all-mixed-content') + } + + if (reportUri) { + policy.push(`report-uri ${reportUri}`) + } + } + + headers.set( + reportOnly + ? 'Content-Security-Policy-Report-Only' + : 'Content-Security-Policy', + policy.join(';'), + ) + return headers +} + +export type ContentSecurityPolicyOptions = { + useDefaults?: boolean + reportOnly?: boolean + directives?: DirectiveOptions +} + +type DirectiveOptions = { + fetch?: FetchDirectiveOptions + document?: DocumentDirectiveOptions + navigation?: NavigationDirectiveOptions + reporting?: ReportingDirectiveOptions + other?: OtherDirectiveOptions + deprecated?: DeprecatedDirectiveOptions +} + +type FetchDirectiveOptions = { + [key in + | 'child-src' + | 'connect-src' + | 'default-src' + | 'fenced-frame-src' + | 'font-src' + | 'frame-src' + | 'img-src' + | 'manifest-src' + | 'media-src' + | 'object-src' + | 'prefetch-src' + | 'script-src' + | 'script-src-elem' + | 'script-src-attr' + | 'style-src' + | 'style-src-elem' + | 'style-src-attr' + | 'worker-src']?: FetchDirectiveSyntax +} + +type DocumentDirectiveOptions = { + 'base-uri'?: "'none'" | SourceExpressionList + sandbox?: SandboxSyntax +} + +type NavigationDirectiveOptions = { + 'form-action'?: "'none'" | SourceExpressionList + 'frame-ancestors'?: "'none'" | SourceExpressionList +} + +type ReportingDirectiveOptions = { + 'report-to'?: string +} + +type OtherDirectiveOptions = { + 'require-trusted-types-for'?: 'script' + 'trusted-types'?: "'none'" | Array + 'upgrade-insecure-requests'?: boolean +} + +type DeprecatedDirectiveOptions = { + 'block-all-mixed-content'?: boolean + 'report-uri'?: string +} + +type SourceExpression = "'self'" | HostSource | SchemeSource | undefined +type SourceExpressionList = Array +type HostSource = SmartString +type SchemeSource = `${Protocol}:` +type Protocol = + | 'blob' + | 'data' + | 'file' + | 'ftp' + | 'http' + | 'https' + | 'javascript' + | 'mailto' + | 'resource ' + | 'ssh' + | 'tel' + | 'urn' + | 'view-source' + | 'ws' + | 'wss' + +type FetchDirectiveSyntax = + | "'none'" + | Array< + | "'unsafe-eval'" + | "'wasm-unsafe-eval'" + | "'unsafe-inline'" + | "'unsafe-hashes'" + | "'inline-speculation-rules'" + | "'strict-dynamic'" + | "'report-sample'" + | `'nonce-${string}'` + | `'${'sha256' | 'sha384' | 'sha512'}-${string}'` + | SourceExpression + | SmartString + > + +type SandboxSyntax = + | 'allow-downloads' + | 'allow-forms' + | 'allow-modals' + | 'allow-orientation-lock' + | 'allow-pointer-lock' + | 'allow-popups' + | 'allow-popups-to-escape-sandbox' + | 'allow-presentation' + | 'allow-same-origin' + | 'allow-scripts' + | 'allow-storage-access-by-user-activation Experimental' + | 'allow-top-navigation' + | 'allow-top-navigation-by-user-activation' + | 'allow-top-navigation-to-custom-protocols' + +function defaultizeDirectives(directives?: DirectiveOptions): DirectiveOptions { + if (!directives) { + return defaultDirectives + } + + return { + fetch: { + ...defaultDirectives.fetch, + ...directives.fetch, + }, + document: { + ...defaultDirectives.document, + ...directives.document, + }, + navigation: { + ...defaultDirectives.navigation, + ...directives.navigation, + }, + reporting: { + ...defaultDirectives.reporting, + ...directives.reporting, + }, + other: { + ...defaultDirectives.other, + ...directives.other, + }, + deprecated: { + ...defaultDirectives.deprecated, + ...directives.deprecated, + }, + } +} + +const defaultDirectives: DirectiveOptions = { + fetch: { + 'default-src': ["'self'"], + 'font-src': ["'self'", 'https:', 'data:'], + 'img-src': ["'self'", 'data:'], + 'object-src': ["'none'"], + 'script-src': ["'self'"], + 'script-src-attr': ["'none'"], + 'style-src': ["'self'", 'https:', "'unsafe-inline'"], + }, + document: { + 'base-uri': ["'self'"], + }, + + navigation: { + 'form-action': ["'self'"], + 'frame-ancestors': ["'self'"], + }, + other: { + 'upgrade-insecure-requests': false, + }, +} diff --git a/app/utils/helmet/middlewares/cross-origin-embedder-policy.ts b/app/utils/helmet/middlewares/cross-origin-embedder-policy.ts new file mode 100644 index 000000000..1ffede37a --- /dev/null +++ b/app/utils/helmet/middlewares/cross-origin-embedder-policy.ts @@ -0,0 +1,14 @@ +export const crossOriginEmbedderPolicy = ( + options: CrossOriginEmbedderPolicyOptions = 'require-corp', +): Headers => { + const headers = new Headers() + headers.set('Cross-Origin-Embedder-Policy', options) + return headers +} + +export type CrossOriginEmbedderPolicyOptions = + CrossOriginEmbedderPolicyDirective +export type CrossOriginEmbedderPolicyDirective = + | 'unsafe-none' + | 'require-corp' + | 'credentialless' diff --git a/app/utils/helmet/middlewares/cross-origin-opener-policy.ts b/app/utils/helmet/middlewares/cross-origin-opener-policy.ts new file mode 100644 index 000000000..625f2b7e2 --- /dev/null +++ b/app/utils/helmet/middlewares/cross-origin-opener-policy.ts @@ -0,0 +1,14 @@ +export const crossOriginOpenerPolicy = ( + options: CrossOriginOpenerPolicyOptions = 'same-origin', +): Headers => { + const headers = new Headers() + headers.set('Cross-Origin-Opener-Policy', options) + return headers +} + +export type CrossOriginOpenerPolicyOptions = CrossOriginOpenerPolicyDirective +export type CrossOriginOpenerPolicyDirective = + | 'unsafe-none' + | 'same-origin' + | 'same-origin-allow-popups' + | 'noopener-allow-popups' diff --git a/app/utils/helmet/middlewares/cross-origin-resource-policy.ts b/app/utils/helmet/middlewares/cross-origin-resource-policy.ts new file mode 100644 index 000000000..ef9a3c213 --- /dev/null +++ b/app/utils/helmet/middlewares/cross-origin-resource-policy.ts @@ -0,0 +1,14 @@ +export const crossOriginResourcePolicy = ( + options: CrossOriginResourcePolicyOptions = 'same-origin', +): Headers => { + const headers = new Headers() + headers.set('Cross-Origin-Resource-Policy', options) + return headers +} + +export type CrossOriginResourcePolicyOptions = + CrossOriginResourcePolicyDirective +export type CrossOriginResourcePolicyDirective = + | 'same-site' + | 'same-origin' + | 'cross-origin' diff --git a/app/utils/helmet/middlewares/index.ts b/app/utils/helmet/middlewares/index.ts new file mode 100644 index 000000000..6d5bdc2ff --- /dev/null +++ b/app/utils/helmet/middlewares/index.ts @@ -0,0 +1,37 @@ +export { + contentSecurityPolicy, + type ContentSecurityPolicyOptions as ContentSecurityPolicyOptions, +} from './content-security-policy.ts' +export { + crossOriginEmbedderPolicy, + type CrossOriginEmbedderPolicyOptions, +} from './cross-origin-embedder-policy.ts' +export { + crossOriginOpenerPolicy, + type CrossOriginOpenerPolicyOptions, +} from './cross-origin-opener-policy.ts' +export { + crossOriginResourcePolicy, + type CrossOriginResourcePolicyOptions, +} from './cross-origin-resource-policy.ts' +export { originAgentCluster } from './origin-agent-cluster.ts' +export { + referrerPolicy, + type ReferrerPolicyOptions, +} from './referrer-policy.ts' +export { + strictTransportSecurity, + type StrictTransportSecurityOptions, +} from './strict-transport-security.ts' +export { xContentTypeOptions } from './x-content-type-options.ts' +export { + xDnsPrefetchControl, + type XDnsPrefetchControlOptions, +} from './x-dns-prefetch-control.ts' +export { xDownloadOptions } from './x-download-options.ts' +export { xFrameOptions, type XFrameOptionsOptions } from './x-frame-options.ts' +export { + xPermittedCrossDomainPolicies, + type XPermittedCrossDomainPoliciesOptions, +} from './x-permitted-cross-domain-policies.ts' +export { xXssProtection } from './x-xss-protection.ts' diff --git a/app/utils/helmet/middlewares/origin-agent-cluster.ts b/app/utils/helmet/middlewares/origin-agent-cluster.ts new file mode 100644 index 000000000..1fa6c79a6 --- /dev/null +++ b/app/utils/helmet/middlewares/origin-agent-cluster.ts @@ -0,0 +1,5 @@ +export const originAgentCluster = (): Headers => { + const headers = new Headers() + headers.set('Origin-Agent-Cluster', '?1') + return headers +} diff --git a/app/utils/helmet/middlewares/referrer-policy.ts b/app/utils/helmet/middlewares/referrer-policy.ts new file mode 100644 index 000000000..459942ee8 --- /dev/null +++ b/app/utils/helmet/middlewares/referrer-policy.ts @@ -0,0 +1,18 @@ +export const referrerPolicy = ( + options: ReferrerPolicyOptions = ['no-referrer'], +): Headers => { + const headers = new Headers() + headers.set('Referrer-Policy', options.join(',')) + return headers +} + +export type ReferrerPolicyOptions = ReferrerPolicyDirective[] +export type ReferrerPolicyDirective = + | 'no-referrer' + | 'no-referrer-when-downgrade' + | 'origin' + | 'origin-when-cross-origin' + | 'same-origin' + | 'strict-origin' + | 'strict-origin-when-cross-origin (default)' + | 'unsafe-url' diff --git a/app/utils/helmet/middlewares/strict-transport-security.ts b/app/utils/helmet/middlewares/strict-transport-security.ts new file mode 100644 index 000000000..56e66e38e --- /dev/null +++ b/app/utils/helmet/middlewares/strict-transport-security.ts @@ -0,0 +1,19 @@ +export const strictTransportSecurity = ({ + maxAge = 365 * 24 * 60 * 60, + includeSubDomains = true, + preload = false, +}: StrictTransportSecurityOptions = {}): Headers => { + const headers = new Headers() + const directives = [`max-age=${maxAge}`] + if (includeSubDomains) directives.push('includeSubDomains') + if (preload) directives.push('preload') + + headers.set('Strict-Transport-Security', directives.join(';')) + return headers +} + +export type StrictTransportSecurityOptions = { + maxAge?: number + includeSubDomains?: boolean + preload?: boolean +} diff --git a/app/utils/helmet/middlewares/x-content-type-options.ts b/app/utils/helmet/middlewares/x-content-type-options.ts new file mode 100644 index 000000000..aa4401468 --- /dev/null +++ b/app/utils/helmet/middlewares/x-content-type-options.ts @@ -0,0 +1,5 @@ +export const xContentTypeOptions = (): Headers => { + const headers = new Headers() + headers.set('X-Content-Type-Options', 'nosniff') + return headers +} diff --git a/app/utils/helmet/middlewares/x-dns-prefetch-control.ts b/app/utils/helmet/middlewares/x-dns-prefetch-control.ts new file mode 100644 index 000000000..5ad7a331f --- /dev/null +++ b/app/utils/helmet/middlewares/x-dns-prefetch-control.ts @@ -0,0 +1,10 @@ +export const xDnsPrefetchControl = ( + options: XDnsPrefetchControlOptions = 'off', +): Headers => { + const headers = new Headers() + headers.set('X-DNS-Prefetch-Control', options) + return headers +} + +export type XDnsPrefetchControlOptions = XDnsPrefetchControlDirective +export type XDnsPrefetchControlDirective = 'on' | 'off' diff --git a/app/utils/helmet/middlewares/x-download-options.ts b/app/utils/helmet/middlewares/x-download-options.ts new file mode 100644 index 000000000..a5e350de5 --- /dev/null +++ b/app/utils/helmet/middlewares/x-download-options.ts @@ -0,0 +1,5 @@ +export const xDownloadOptions = (): Headers => { + const headers = new Headers() + headers.set('X-Download-Options', 'noopen') + return headers +} diff --git a/app/utils/helmet/middlewares/x-frame-options.ts b/app/utils/helmet/middlewares/x-frame-options.ts new file mode 100644 index 000000000..07adf40cf --- /dev/null +++ b/app/utils/helmet/middlewares/x-frame-options.ts @@ -0,0 +1,10 @@ +export const xFrameOptions = ( + options: XFrameOptionsOptions = 'SAMEORIGIN', +): Headers => { + const headers = new Headers() + headers.set('X-Frame-Options', options) + return headers +} + +export type XFrameOptionsOptions = XFrameOptionsDirective +export type XFrameOptionsDirective = 'DENY' | 'SAMEORIGIN' diff --git a/app/utils/helmet/middlewares/x-permitted-cross-domain-policies.ts b/app/utils/helmet/middlewares/x-permitted-cross-domain-policies.ts new file mode 100644 index 000000000..fa5aed756 --- /dev/null +++ b/app/utils/helmet/middlewares/x-permitted-cross-domain-policies.ts @@ -0,0 +1,17 @@ +export const xPermittedCrossDomainPolicies = ( + options: XPermittedCrossDomainPoliciesOptions = 'none', +): Headers => { + const headers = new Headers() + headers.set('X-Permitted-Cross-Domain-Policies', options) + return headers +} + +export type XPermittedCrossDomainPoliciesOptions = + XPermittedCrossDomainPoliciesDirective +export type XPermittedCrossDomainPoliciesDirective = + | 'none' + | 'master-only' + | 'by-content-type' + | 'by-ftp-filename' + | 'all' + | 'none-this-response' diff --git a/app/utils/helmet/middlewares/x-xss-protection.ts b/app/utils/helmet/middlewares/x-xss-protection.ts new file mode 100644 index 000000000..2cc29c2d8 --- /dev/null +++ b/app/utils/helmet/middlewares/x-xss-protection.ts @@ -0,0 +1,5 @@ +export const xXssProtection = (): Headers => { + const headers = new Headers() + headers.set('X-XSS-Protection', '0') + return headers +} diff --git a/app/utils/misc.tsx b/app/utils/misc.tsx index 03567675e..c057dd3d7 100644 --- a/app/utils/misc.tsx +++ b/app/utils/misc.tsx @@ -288,3 +288,8 @@ export async function downloadFile(url: string, retries: number = 0) { return downloadFile(url, retries + 1) } } + +/** + * string type but keeps auto-completion + */ +export type SmartString = string & Record diff --git a/package-lock.json b/package-lock.json index 91cee3351..ef2258812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,6 @@ "express-rate-limit": "^7.5.0", "get-port": "^7.1.0", "glob": "^11.0.1", - "helmet": "^8.0.0", "input-otp": "^1.4.2", "intl-parse-accept-language": "^1.0.0", "isbot": "^5.1.22", @@ -9723,15 +9722,6 @@ "dev": true, "license": "MIT" }, - "node_modules/helmet": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.0.0.tgz", - "integrity": "sha512-VyusHLEIIO5mjQPUI1wpOAEu+wl6Q0998jzTxqUYGE45xCIcAxy3MsbEK/yyJUJ3ADeMoB6MornPH6GMWAf+Pw==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", diff --git a/package.json b/package.json index b91ebda0d..398c2dba4 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "express-rate-limit": "^7.5.0", "get-port": "^7.1.0", "glob": "^11.0.1", - "helmet": "^8.0.0", "input-otp": "^1.4.2", "intl-parse-accept-language": "^1.0.0", "isbot": "^5.1.22", diff --git a/server/index.ts b/server/index.ts index 98fa792cb..5fb3ff431 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,7 +8,6 @@ import compression from 'compression' import express from 'express' import rateLimit from 'express-rate-limit' import getPort, { portNumbers } from 'get-port' -import helmet from 'helmet' import morgan from 'morgan' import { type ServerBuild } from 'react-router' @@ -110,39 +109,6 @@ app.use((_, res, next) => { next() }) -app.use( - helmet({ - xPoweredBy: false, - referrerPolicy: { policy: 'same-origin' }, - crossOriginEmbedderPolicy: false, - contentSecurityPolicy: { - // NOTE: Remove reportOnly when you're ready to enforce this CSP - reportOnly: true, - directives: { - 'connect-src': [ - MODE === 'development' ? 'ws:' : null, - process.env.SENTRY_DSN ? '*.sentry.io' : null, - "'self'", - ].filter(Boolean), - 'font-src': ["'self'"], - 'frame-src': ["'self'"], - 'img-src': ["'self'", 'data:'], - 'script-src': [ - "'strict-dynamic'", - "'self'", - // @ts-expect-error - (_, res) => `'nonce-${res.locals.cspNonce}'`, - ], - 'script-src-attr': [ - // @ts-expect-error - (_, res) => `'nonce-${res.locals.cspNonce}'`, - ], - 'upgrade-insecure-requests': null, - }, - }, - }), -) - // When running tests or running in development, we want to effectively disable // rate limiting because playwright tests are very fast and we don't want to // have to wait for the rate limit to reset between tests.