Skip to content
Closed
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
9 changes: 8 additions & 1 deletion app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
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'

Check warning on line 18 in app/entry.server.tsx

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

`./utils/misc.tsx` import should occur before import of `./utils/nonce-provider.ts`
import { helmet } from './utils/helmet.server.ts'

Check warning on line 19 in app/entry.server.tsx

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

`./utils/helmet.server.ts` import should occur before import of `./utils/litefs.server.ts`

export const streamTimeout = 5000

Expand Down Expand Up @@ -65,9 +67,14 @@
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,
}),
)
Expand Down
17 changes: 11 additions & 6 deletions app/routes/resources+/note-images.$imageId.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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(),
),
})
}
46 changes: 46 additions & 0 deletions app/utils/helmet.server.ts
Original file line number Diff line number Diff line change
@@ -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}'`],
},
},
},
},
})
}
202 changes: 202 additions & 0 deletions app/utils/helmet/index.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading