Skip to content

Commit

Permalink
feat: add server csp config options (#281)
Browse files Browse the repository at this point in the history
  • Loading branch information
stevenle authored Mar 13, 2024
1 parent 87282c6 commit de578dd
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 23 deletions.
5 changes: 5 additions & 0 deletions examples/blog/layouts/BaseLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,18 @@ export function BaseLayout(props: BaseLayoutProps) {
<meta content={title} name="twitter:title" />
<meta name="description" content={description} />
<meta name="og:description" content={description} />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;900&display=swap"
/>
{image && <meta content={image} property="og:image" />}
{image && <meta content={image} name="twitter:image" />}
{image && <meta content="summary_large_image" name="twitter:card" />}
{noindex && <meta name="robots" content="noindex" />}
</Head>
<div id="root">
<main id="main">{props.children}</main>
<script>console.log('hello world');</script>
</div>
<GridOverlay />
</>
Expand Down
53 changes: 43 additions & 10 deletions packages/root-cms/core/app.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import crypto from 'node:crypto';
import path from 'node:path';
import {fileURLToPath} from 'node:url';

import {Request, Response} from '@blinkk/root';
import {render as renderToString} from 'preact-render-to-string';

import {Collection} from './schema.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
Expand All @@ -27,17 +26,19 @@ function App(props: AppProps) {
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;900&display=swap"
nonce="{NONCE}"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@500&display=swap"
nonce="{NONCE}"
/>
<link
rel="icon"
href="https://lh3.googleusercontent.com/ijK50TfQlV_yJw3i-CMlnD6osH4PboZBILZrJcWhoNMEmoyCD5e1bAxXbaOPe5w4gG_Scf37EXrmZ6p8sP2lue5fLZ419m5JyLMs=e385-w256"
type="image/png"
/>
<link rel="stylesheet" href="{CSS_URL}" />
<link rel="stylesheet" href="{CSS_URL}" nonce="{NONCE}" />
</head>
<body>
<div id="root">
Expand All @@ -53,8 +54,9 @@ function App(props: AppProps) {
dangerouslySetInnerHTML={{
__html: `window.__ROOT_CTX = ${JSON.stringify(props.ctx)}`,
}}
nonce="{NONCE}"
/>
<script type="module" src="{JS_URL}"></script>
<script type="module" src="{JS_URL}" nonce="{NONCE}"></script>
</body>
</html>
);
Expand Down Expand Up @@ -95,19 +97,23 @@ export async function renderApp(req: Request, res: Response, options: any) {

const mainHtml = renderToString(<App title={title} ctx={ctx} />);
let html = `<!doctype html>\n${mainHtml}`;
const nonce = generateNonce();
if (req.viteServer) {
const uiCssPath = path.join(__dirname, 'ui/ui.css');
const uiJsPath = path.join(__dirname, 'ui/ui.js');
const tpl = html
.replace('{CSS_URL}', `/@fs${uiCssPath}`)
.replace('{JS_URL}', `/@fs${uiJsPath}`);
.replace('{JS_URL}', `/@fs${uiJsPath}`)
.replaceAll('{NONCE}', nonce);
html = await req.viteServer!.transformIndexHtml(req.originalUrl, tpl);
} else {
html = html
.replace('{CSS_URL}', '/cms/static/ui.css')
.replace('{JS_URL}', '/cms/static/ui.js');
.replace('{JS_URL}', '/cms/static/ui.js')
.replaceAll('{NONCE}', nonce);
}
res.setHeader('Content-Type', 'text/html');
setSecurityHeaders(res, nonce);
res.send(html);
}

Expand All @@ -132,13 +138,14 @@ function SignIn(props: SignInProps) {
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Google+Sans:400,500&display=swap"
nonce="{NONCE}"
/>
<link
rel="icon"
href="https://lh3.googleusercontent.com/ijK50TfQlV_yJw3i-CMlnD6osH4PboZBILZrJcWhoNMEmoyCD5e1bAxXbaOPe5w4gG_Scf37EXrmZ6p8sP2lue5fLZ419m5JyLMs=e385-w256"
type="image/png"
/>
<link rel="stylesheet" href="{CSS_URL}" />
<link rel="stylesheet" href="{CSS_URL}" nonce="{NONCE}" />
</head>
<body>
<div id="root">
Expand All @@ -154,8 +161,9 @@ function SignIn(props: SignInProps) {
dangerouslySetInnerHTML={{
__html: `window.__ROOT_CTX = ${JSON.stringify(props.ctx)}`,
}}
nonce="{NONCE}"
/>
<script type="module" src="{JS_URL}"></script>
<script type="module" src="{JS_URL}" nonce="{NONCE}"></script>
</body>
</html>
);
Expand All @@ -164,18 +172,43 @@ export async function renderSignIn(req: Request, res: Response, options: any) {
const ctx = {name: options.name, firebaseConfig: options.firebaseConfig};
const mainHtml = renderToString(<SignIn title="Sign in" ctx={ctx} />);
let html = `<!doctype html>\n${mainHtml}`;
const nonce = generateNonce();
if (req.viteServer) {
const cssPath = path.join(__dirname, 'ui/signin.css');
const jsPath = path.join(__dirname, 'ui/signin.js');
const tpl = html
.replace('{CSS_URL}', `/@fs${cssPath}`)
.replace('{JS_URL}', `/@fs${jsPath}`);
.replace('{JS_URL}', `/@fs${jsPath}`)
.replaceAll('{NONCE}', nonce);
html = await req.viteServer!.transformIndexHtml(req.originalUrl, tpl);
} else {
html = html
.replace('{CSS_URL}', '/cms/static/signin.css')
.replace('{JS_URL}', '/cms/static/signin.js');
.replace('{JS_URL}', '/cms/static/signin.js')
.replaceAll('{NONCE}', nonce);
}
res.setHeader('Content-Type', 'text/html');
setSecurityHeaders(res, nonce);
res.send(html);
}

function generateNonce() {
return crypto.randomBytes(16).toString('base64');
}

function setSecurityHeaders(res: Response, nonce: string) {
res.setHeader('x-frame-options', 'SAMEORIGIN');
res.setHeader(
'strict-transport-security',
'max-age=63072000; includeSubdomains; preload'
);
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('x-xss-protection', '1; mode=block');

const directives = [
"base-uri 'none'",
"object-src 'none'",
`script-src 'self' 'unsafe-eval' 'nonce-${nonce}' *.google.com`,
];
res.setHeader('content-security-policy-report-only', directives.join(';'));
}
34 changes: 30 additions & 4 deletions packages/root-password-protect/core/password-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import crypto from 'node:crypto';
import {Request, Response} from '@blinkk/root';
import {render as renderToString} from 'preact-render-to-string';

Expand Down Expand Up @@ -129,8 +128,9 @@ function PasswordPage(props: PasswordPageProps) {
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@500&display=swap"
nonce="{NONCE}"
/>
<style>{CSS}</style>
<style nonce="{NONCE}">{CSS}</style>
</head>
<body>
<div id="root">
Expand Down Expand Up @@ -193,8 +193,34 @@ export async function renderPasswordPage(
res: Response,
props?: PasswordPageProps
) {
const mainHtml = renderToString(<PasswordPage {...props} />);
const nonce = generateNonce();
const mainHtml = renderToString(<PasswordPage {...props} />).replaceAll(
'{NONCE}',
nonce
);
const html = `<!doctype html>\n${mainHtml}`;
res.setHeader('Content-Type', 'text/html');
setSecurityHeaders(res, nonce);
res.send(html);
}

function generateNonce() {
return crypto.randomBytes(16).toString('base64');
}

function setSecurityHeaders(res: Response, nonce: string) {
res.setHeader('x-frame-options', 'SAMEORIGIN');
res.setHeader(
'strict-transport-security',
'max-age=63072000; includeSubdomains; preload'
);
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('x-xss-protection', '1; mode=block');

const directives = [
"base-uri 'none'",
"object-src 'none'",
`script-src 'self' 'nonce-${nonce}'`,
];
res.setHeader('content-security-policy-report-only', directives.join(';'));
}
52 changes: 52 additions & 0 deletions packages/root/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,52 @@ export interface RootHeaderConfig {
}>;
}

export interface ContentSecurityPolicyConfig {
directives?: Record<string, string[]>;
reportOnly?: boolean;
}

export interface XFrameOptionsConfig {
action: 'DENY' | 'SAMEORIGIN';
}

export interface RootSecurityConfig {
/**
* Content-Security-Policy config. If enabled, a nonce is auto-generated
* for every request and appended to script and stylesheet tags. You can
* validate your CSP headers using a tool like {@link https://csp-evaluator.withgoogle.com/}.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP}
*/
contentSecurityPolicy?: ContentSecurityPolicyConfig | boolean;

/**
* Strict-Transport-Security config. When enabled, the header value is set
* to `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`.
*/
strictTransportSecurity?: boolean;

/**
* X-Content-Type-Options config. When enabled, the header value is set to
* `X-Content-Type-Options: nosniff`.
*/
xContentTypeOptions?: boolean;

/**
* X-Frame-Options config. Setting this value to `true` will default the
* header value to `X-Frame-Options: SAMEORIGIN`.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options}
*/
xFrameOptions?: 'DENY' | 'SAMEORIGIN' | boolean;

/**
* X-XSS-Protection config. When enabled, the header value is set to
* `X-XSS-Protection: 1; mode=block`.
*/
xXssProtection?: boolean;
}

export interface RootServerConfig {
/**
* An array of middleware to add to the express server. These middleware are
Expand Down Expand Up @@ -162,6 +208,12 @@ export interface RootServerConfig {
* HTTP headers to add to a response.
*/
headers?: RootHeaderConfig[];

/**
* HTTP security settings. By default, all security settings are enabled with
* commonly used default values.
*/
security?: RootSecurityConfig;
}

export function defineConfig(config: RootUserConfig): RootUserConfig {
Expand Down
2 changes: 2 additions & 0 deletions packages/root/src/core/hooks/useRequestContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface RequestContext {
locale: string;
/** Translations map for the current locale. */
translations: Record<string, string>;
/** CSP nonce value. */
nonce?: string;
}

export const REQUEST_CONTEXT = createContext<RequestContext | null>(null);
Expand Down
Loading

0 comments on commit de578dd

Please sign in to comment.