From 33cf6c87e899b54865fe6f1d82b5dab469e7a5fe Mon Sep 17 00:00:00 2001 From: Chris Sauve Date: Wed, 23 Oct 2024 14:27:02 -0400 Subject: [PATCH] Move headers to be server-only in default templates --- .changeset/honest-ghosts-obey.md | 7 + packages/browser/package.json | 1 + packages/browser/source/headers.ts | 916 ++++++++++++++++++ packages/browser/source/server.ts | 1 + packages/browser/tsconfig.json | 6 +- packages/create/templates/app-basic/App.tsx | 7 +- .../templates/app-basic/foundation/html.ts | 2 +- .../app-basic/foundation/html/HTML.test.tsx | 46 - .../app-basic/foundation/html/HTML.tsx | 159 --- .../app-basic/foundation/html/Head.test.tsx | 42 + .../app-basic/foundation/html/Head.tsx | 34 + .../create/templates/app-basic/server.tsx | 90 +- .../create/templates/app-empty/server.tsx | 3 +- packages/create/templates/app-graphql/App.tsx | 7 +- .../templates/app-graphql/foundation/html.ts | 2 +- .../app-graphql/foundation/html/HTML.test.tsx | 46 - .../app-graphql/foundation/html/HTML.tsx | 159 --- .../app-graphql/foundation/html/Head.test.tsx | 42 + .../app-graphql/foundation/html/Head.tsx | 34 + .../create/templates/app-graphql/server.tsx | 96 +- packages/create/templates/app-trpc/App.tsx | 7 +- .../templates/app-trpc/foundation/html.ts | 2 +- .../app-trpc/foundation/html/HTML.test.tsx | 46 - .../app-trpc/foundation/html/HTML.tsx | 159 --- .../app-trpc/foundation/html/Head.test.tsx | 42 + .../app-trpc/foundation/html/Head.tsx | 34 + packages/create/templates/app-trpc/server.tsx | 99 +- .../source/server/hooks/cache-control.ts | 112 +-- .../server/hooks/content-security-policy.ts | 438 +-------- .../source/server/hooks/permissions-policy.ts | 296 +----- .../server/hooks/strict-transport-security.ts | 50 +- pnpm-lock.yaml | 3 + 32 files changed, 1467 insertions(+), 1521 deletions(-) create mode 100644 .changeset/honest-ghosts-obey.md create mode 100644 packages/browser/source/headers.ts delete mode 100644 packages/create/templates/app-basic/foundation/html/HTML.test.tsx delete mode 100644 packages/create/templates/app-basic/foundation/html/HTML.tsx create mode 100644 packages/create/templates/app-basic/foundation/html/Head.test.tsx create mode 100644 packages/create/templates/app-basic/foundation/html/Head.tsx delete mode 100644 packages/create/templates/app-graphql/foundation/html/HTML.test.tsx delete mode 100644 packages/create/templates/app-graphql/foundation/html/HTML.tsx create mode 100644 packages/create/templates/app-graphql/foundation/html/Head.test.tsx create mode 100644 packages/create/templates/app-graphql/foundation/html/Head.tsx delete mode 100644 packages/create/templates/app-trpc/foundation/html/HTML.test.tsx delete mode 100644 packages/create/templates/app-trpc/foundation/html/HTML.tsx create mode 100644 packages/create/templates/app-trpc/foundation/html/Head.test.tsx create mode 100644 packages/create/templates/app-trpc/foundation/html/Head.tsx diff --git a/.changeset/honest-ghosts-obey.md b/.changeset/honest-ghosts-obey.md new file mode 100644 index 000000000..d13ca3599 --- /dev/null +++ b/.changeset/honest-ghosts-obey.md @@ -0,0 +1,7 @@ +--- +'@quilted/preact-browser': patch +'@quilted/browser': patch +'@quilted/create': patch +--- + +Move headers to be server-only in default templates diff --git a/packages/browser/package.json b/packages/browser/package.json index f49931738..647c0f05f 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -61,6 +61,7 @@ }, "dependencies": { "@quilted/assets": "workspace:^0.1.5", + "@quilted/http": "workspace:^0.3.0", "@quilted/signals": "workspace:^0.2.1", "js-cookie": "^3.0.0" }, diff --git a/packages/browser/source/headers.ts b/packages/browser/source/headers.ts new file mode 100644 index 000000000..c85b0eb50 --- /dev/null +++ b/packages/browser/source/headers.ts @@ -0,0 +1,916 @@ +import type { + ContentSecurityPolicySandboxAllow, + ContentSecurityPolicySpecialSource, +} from '@quilted/http'; + +export type { + ContentSecurityPolicySandboxAllow, + ContentSecurityPolicySpecialSource, + ContentSecurityPolicyDirective, + CrossOriginEmbedderPolicyHeaderValue, + CrossOriginOpenerPolicyHeaderValue, + CrossOriginResourcePolicyHeaderValue, + PermissionsPolicyDirective, + PermissionsPolicySpecialSource, +} from '@quilted/http'; + +const SPECIAL_SOURCES = new Set(['*', 'self', 'src']); + +// The recommendation for being added to Google’s preload list is +// two years. See https://hstspreload.org/ for details. +const STRICT_TRANSPORT_SECURITY_DEFAULT_MAX_AGE = 63_072_000; + +/** + * Options for controlling the Cache-Control header of the current + * response. + * + * @see https://csswizardry.com/2019/03/cache-control-for-civilians/ + */ +export type CacheControlOptions = + | { + /** + * Whether this cache control directive applies only to “private” + * caches, which is typically the end user’s browser cache for + * applications using Quilt. When not set, the cache-control directive + * will be set to `public`, which allows all caches (including CDNs, + * proxies, and the like) to cache the content. + */ + private?: true; + /** + * Completely disable caching of this response. Passing this option + * sets the `Cache-Control` header to `no-store`. + */ + cache: false; + maxAge?: never; + immutable?: never; + revalidate?: never; + } + | { + /** + * Whether this cache control directive applies only to “private” + * caches, which is typically the end user’s browser cache for + * applications using Quilt. When not set, the cache-control directive + * will be set to `public`, which allows all caches (including CDNs, + * proxies, and the like) to cache the content. + */ + private?: true; + cache?: never; + /** + * The number of seconds, from the time of this request, that + * the content is considered “fresh”. + */ + maxAge?: number; + immutable?: never; + /** + * Controls how clients should revalidate their cached content. + * If this option is set to `true`, the resulting `Cache-Control` + * header will have the `must-revalidate` directive, which asks + * clients to revalidate their content after the `maxAge` period + * has expired. + * + * If you instead pass `{allowStale: number}`, the resulting + * `Cache-Control` header will have the `stale-while-revalidate` + * directive, which allows caches that support it to use the stale + * version from the cache while they perform revalidation in the + * background. + */ + revalidate?: boolean | {allowStale: number}; + } + | { + /** + * Whether this cache control directive applies only to “private” + * caches, which is typically the end user’s browser cache for + * applications using Quilt. When not set, the cache-control directive + * will be set to `public`, which allows all caches (including CDNs, + * proxies, and the like) to cache the content. + */ + private?: true; + cache?: never; + maxAge?: number; + /** + * Declares that this request is immutable. This means that it is + * assumed to never change, and clients will never attempt to + * revalidate the content. Be careful when using this option! + */ + immutable?: boolean; + revalidate?: never; + }; + +export function cacheControlHeader(options: CacheControlOptions) { + let headerValue = ''; + + const { + private: isPrivate, + cache, + immutable, + maxAge = immutable ? 31536000 : undefined, + revalidate, + } = options; + + headerValue = isPrivate ? 'private' : 'public'; + + const appendToHeader = (value: string) => { + headerValue = `${headerValue}, ${value}`; + }; + + if (cache === false) { + appendToHeader('no-store'); + } + + if (maxAge === 0 && revalidate === true) { + appendToHeader('no-cache'); + } else if (typeof maxAge === 'number' || revalidate) { + appendToHeader(`max-age=${maxAge ?? 0}`); + + if (revalidate === true) { + appendToHeader('must-revalidate'); + } else if (typeof revalidate === 'object') { + appendToHeader(`stale-while-revalidate=${revalidate.allowStale}`); + } + } + + if (immutable) appendToHeader('immutable'); + + return headerValue; +} + +/** + * Options for creating a content security policy. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + */ +export interface ContentSecurityPolicyOptions { + /** + * Sets the child-src content security policy directive, which determines what sources + * are allowed for nested browsing contexts, including workers and iframes. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/child-src + */ + childSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the connect-src content security policy directive, which determines what sources + * can be connected to from script sources (for example, when using `fetch()`). + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src + */ + connectSources?: (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the default-src content security policy directive, which determines what sources + * are allowed for resources where a more specific policy has not been set. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src + */ + defaultSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the font-src content security policy directive, which determines what sources + * are allowed for fonts used on this page. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/font-src + */ + fontSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the frame-src content security policy directive, which determines what sources + * are allowed for frames embedded in this page. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src + */ + frameSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the img-src content security policy directive, which determines what sources + * are allowed for images. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src + */ + imageSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the manifest-src content security policy directive, which determines what sources + * are allowed for the app manifest for this page. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/manifest-src + */ + manifestSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the media-src content security policy directive, which determines what sources + * are allowed for audio and video. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/media-src + */ + mediaSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the object-src content security policy directive, which determines what sources + * are allowed for ``, ``, and `` elements. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/object-src + */ + objectSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the prefetch-src content security policy directive, which determines what sources + * are allowed for prefetching and prerendering. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/prefetch-src + */ + prefetchSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the script-src content security policy directive, which determines what sources + * are allowed for scripts. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src + */ + scriptSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the script-src-elem content security policy directive, which determines what sources + * are allowed for `script` elements. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src-elem + */ + scriptElementSources?: + | false + | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the script-src-attr content security policy directive, which determines what sources + * are allowed for inline event handlers. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src-attr + */ + scriptAttributeSources?: + | false + | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the style-src content security policy directive, which determines what sources + * are allowed for stylesheets. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src + */ + styleSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the style-src-elem content security policy directive, which determines what sources + * are allowed for stylesheet elements. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src-elem + */ + styleElementSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the style-src-attr content security policy directive, which determines what sources + * are allowed for inline styles. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src-attr + */ + styleAttributeSources?: + | false + | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the worker-src content security policy directive, which determines what sources + * are allowed for workers created by this page. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/worker-src + */ + workerSources?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the base-uri content security policy directive, which determines the sources that can + * be used in the document’s `` element. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/base-uri + */ + baseUri?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Apply restrictions to the abilities of this HTML page using the `sandbox` + * content security policy directive. When set to `true`, the `sandbox` header + * is set without any exceptions. You can also pass an allow-list of capabilities + * to this option to apply the sandbox, with some features enabled. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox + */ + sandbox?: boolean | ContentSecurityPolicySandboxAllow[]; + + /** + * Sets the form-action content security policy directive, which determines the sources + * that can be the targets of form submissions on this page. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/form-action + */ + formActions?: + | false + | ( + | ContentSecurityPolicySpecialSource.Self + | ContentSecurityPolicySpecialSource.UnsafeEval + | ContentSecurityPolicySpecialSource.UnsafeHashes + | ContentSecurityPolicySpecialSource.UnsafeInline + | ContentSecurityPolicySpecialSource.None + | string + )[]; + + /** + * Sets the frame-ancestors content security policy directive, which determines what sources + * can embed this page. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors + */ + frameAncestors?: + | false + | ( + | ContentSecurityPolicySpecialSource.Self + | ContentSecurityPolicySpecialSource.None + | string + )[]; + + /** + * Sets the navigate-to content security policy directive, which determines what + * sources the page can navigate to. + * + * Passing `false` or an empty array is equivalent to passing `["'none'"]`, disallowing + * all sources. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/navigate-to + */ + navigateTo?: false | (ContentSecurityPolicySpecialSource | string)[]; + + /** + * Sets the report-uri content security policy directive which controls the URL + * the browser will report to when content security policy violations occur. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/navigate-to + */ + reportUri?: string; + + /** + * Sets the `report-to` group that the browser will report content security + * policy violations to. You will typically need to use this in conjunction + * with setting the `Report-To` header, using either `useResponseHeader()` or + * ``. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-to + */ + reportTo?: string; + + /** + * Sets the `block-all-mixed-content` content security policy directive, which + * prevents insecure content from loading on when the page uses HTTP. + * + * @deprecated Use the `upgradeInsecureRequests` option instead. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/block-all-mixed-content + */ + blockAllMixedContent?: boolean; + + /** + * Instructs the browser to upgrade any insecure requests made by the page as + * if they had been secure requests. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/upgrade-insecure-requests + */ + upgradeInsecureRequests?: boolean; + + /** + * Determines what resources the browser will require sub-resource integrity (SRI) + * checks before loading. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/require-sri-for + */ + requireSriFor?: ('style' | 'script')[]; + + /** + * Determines what resources will only accept “trusted types” in potential XSS + * vectors, like when setting `Element.innerHTML`. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/require-trusted-types-for + */ + requireTrustedTypesFor?: 'script'[]; +} + +export function contentSecurityPolicyHeader( + options: ContentSecurityPolicyOptions = {}, +) { + let headerValue = ''; + + const { + baseUri, + blockAllMixedContent, + childSources, + connectSources, + defaultSources, + fontSources, + formActions, + frameAncestors, + frameSources, + imageSources, + manifestSources, + mediaSources, + navigateTo, + objectSources, + prefetchSources, + reportTo, + reportUri, + requireSriFor, + requireTrustedTypesFor, + sandbox, + scriptAttributeSources, + scriptElementSources, + scriptSources, + styleAttributeSources, + styleElementSources, + styleSources, + upgradeInsecureRequests, + workerSources, + } = options; + + const appendContent = (content: string) => { + if (headerValue.length === 0) { + headerValue = content; + } else { + headerValue += `; ${content}`; + } + }; + + const addDirective = (directive: string, value?: string) => + appendContent(`${directive}${value ? ` ${value}` : ''}`); + + const addSourcesDirective = ( + directive: string, + value?: boolean | string[], + ) => { + if (value == null) return; + + if (value === false) { + addDirective(directive, "'none'"); + } else if (Array.isArray(value)) { + if (value.length === 0) { + addDirective(directive, "'none'"); + } else { + addDirective(directive, value.join(' ')); + } + } + }; + + addSourcesDirective('default-src', defaultSources); + addSourcesDirective('connect-src', connectSources); + addSourcesDirective('child-src', childSources); + addSourcesDirective('font-src', fontSources); + addSourcesDirective('form-action', formActions); + addSourcesDirective('frame-ancestors', frameAncestors); + addSourcesDirective('frame-src', frameSources); + addSourcesDirective('img-src', imageSources); + addSourcesDirective('manifest-src', manifestSources); + addSourcesDirective('media-src', mediaSources); + addSourcesDirective('object-src', objectSources); + addSourcesDirective('prefetch-src', prefetchSources); + addSourcesDirective('script-src', scriptSources); + addSourcesDirective('script-src-attr', scriptAttributeSources); + addSourcesDirective('script-src-elem', scriptElementSources); + addSourcesDirective('style-src', styleSources); + addSourcesDirective('style-src-attr', styleAttributeSources); + addSourcesDirective('style-src-elem', styleElementSources); + addSourcesDirective('worker-src', workerSources); + + addSourcesDirective('base-uri', baseUri); + addSourcesDirective('navigate-to', navigateTo); + + if (sandbox === true) { + addDirective('sandbox'); + } else if (Array.isArray(sandbox)) { + if (sandbox.length === 0) { + addDirective('sandbox'); + } else { + addDirective('sandbox', sandbox.join(' ')); + } + } + + if (requireSriFor && requireSriFor.length > 0) { + addDirective('require-sri-for', requireSriFor.join(' ')); + } + + if (requireTrustedTypesFor && requireTrustedTypesFor.length > 0) { + addDirective( + 'require-trusted-types-for', + requireTrustedTypesFor.map((type) => `'${type}'`).join(' '), + ); + } + + if (blockAllMixedContent) addDirective('block-all-mixed-content'); + if (upgradeInsecureRequests) addDirective('upgrade-insecure-requests'); + + if (reportTo) addDirective('report-to', reportTo); + if (reportUri) addDirective('report-uri', reportUri); + + return headerValue; +} + +import type {PermissionsPolicySpecialSource} from '@quilted/http'; + +/** + * Options for creating a content security policy. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + */ +export interface PermissionsPolicyOptions { + /** + * Controls whether the current document is allowed to gather information about the acceleration of + * the device through the Accelerometer interface. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy/accelerometer + */ + accelerometer?: false | (PermissionsPolicySpecialSource | string)[]; + + /** + * Controls whether the current document is allowed to gather information + * about the amount of light in the environment around the device through + * the AmbientLightSensor interface. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy/ambient-light-sensor + */ + ambientLightSensor?: false | (PermissionsPolicySpecialSource | string)[]; + + /** + * Controls whether the current document is allowed to autoplay media requested through the + * HTMLMediaElement interface. This includes both the use of the `autoplay` + * attribute on `