Skip to content
Open
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
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ RUN CI=true pnpm install
COPY src src
COPY static static
COPY .env svelte.config.js tsconfig.json vite.config.ts ./
ARG CSP_EXTRA_SCRIPT_SRC=""
ARG CSP_EXTRA_STYLE_SRC=""
ARG CSP_EXTRA_IMG_SRC=""
ARG CSP_EXTRA_CONNECT_SRC=""
RUN pnpm build \
&& pnpm prune --prod

Expand Down
2 changes: 2 additions & 0 deletions httpd-picsure.conf
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ LoadModule rewrite_module modules/mod_rewrite.so
Options -Indexes +FollowSymLinks
</Directory>

ServerTokens Prod

Include conf/extra/httpd-vhosts.conf
16 changes: 3 additions & 13 deletions src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,11 @@
<meta charset="utf-8" />
<meta name="color-scheme" content="light" />
<meta name="viewport" content="width=device-width" />
<style id="antiClickjack">
body {
display: none !important;
}
</style>
<script type="text/javascript">
if (self === top) {
document.getElementById('antiClickjack').parentNode.removeChild(antiClickjack);
} else {
top.location = self.location;
}
</script>
<!-- Plotly writes its global rules in here; must stay empty. -->
<style id="plotly.js-style-global" nonce="%sveltekit.nonce%"></style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="">
<div style="display: contents">%sveltekit.body%</div>
<div class="contents">%sveltekit.body%</div>
</body>
</html>
13 changes: 12 additions & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { registerProviderData } from './lib/AuthProviderRegistry';
import type { AuthData } from './lib/models/AuthProvider';
import { getConfig } from './lib/server/configCache';
import { runWithConfig } from './lib/server/configuration';
import { findStyleNonceProblem, withStyleNonce } from './lib/server/csp';

const PROVIDER_PREFIX = 'VITE_AUTH_PROVIDER_MODULE_';

Expand Down Expand Up @@ -47,7 +48,17 @@ registerEnabledProviders(enabledProviders, PROVIDER_PREFIX);
// Wraps each request in an isolated config store so concurrent requests can't
// observe each other's config (see lib/server/configuration.ts).
export const handle: Handle = async ({ event, resolve }) => {
return runWithConfig(() => resolve(event));
const response = await runWithConfig(() => resolve(event));
const csp = withStyleNonce(response.headers.get('content-security-policy'));
if (csp) response.headers.set('content-security-policy', csp);
const problem = findStyleNonceProblem(csp);
if (problem) {
const message = `CSP cannot authorise the inline <style> seeded in app.html: ${problem}`;
if (import.meta.env.DEV) throw new Error(message);
console.error(message);
}

return response;
};

export const handleError: HandleServerError = async ({ error, event, status, message }) => {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/components/Popover.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { css } from '$lib/utilities/style';
import type { Snippet } from 'svelte';
import {
FloatingArrow,
Expand Down Expand Up @@ -127,7 +128,9 @@
bind:this={floating.elements.floating}
class="popover {size}"
aria-label={title || 'Help popover'}
style="background-color: var(--color-{color}-100); opacity: 0.95;{floating.floatingStyles}"
{@attach css(
`background-color: var(--color-${color}-100); opacity: 0.95;${floating.floatingStyles}`,
)}
{...interactions.getFloatingProps()}
data-testid={testid}
transition:fade={{ duration: 200 }}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/Shell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
</script>

{#if showShell}
<div style="display: contents">
<div class="contents">
<main class="w-full h-full">
<div
id="appShell"
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/explorer/FacetItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
aria-checked={checked}
onclick={onClick}
/>
<span class:opacity-75={facet.count === 0} style="word-wrap: break-word; overflow-wrap: anywhere;"
<span class:opacity-75={facet.count === 0} class="wrap-anywhere"
>{`${facet.display} (${facet.count?.toLocaleString()})`}</span
>
</label>
Expand Down
6 changes: 4 additions & 2 deletions src/lib/components/explorer/FacetPlaceholder.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts">
import { css } from '$lib/utilities/style';

let {
className = '',
numFacets = 3,
Expand Down Expand Up @@ -38,7 +40,7 @@
<div
data-testid="facet-placeholder-item-{i}"
class="flex items-center space-x-3"
style="opacity: {opacity}"
{@attach css({ opacity })}
>
<div class="placeholder size-4 animate-pulse rounded-sm"></div>
<div
Expand All @@ -54,7 +56,7 @@
<div
data-testid="facet-placeholder-show-more-button"
class="flex justify-center mt-3"
style="opacity: {fadeEffect ? 0.1 : 1}"
{@attach css({ opacity: fadeEffect ? 0.1 : 1 })}
>
<div class="placeholder h-8 w-24 animate-pulse rounded-full"></div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/lib/components/explorer/advanced/AdvancedGroup.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { css } from '$lib/utilities/style';
import { useSortable } from '@dnd-kit-svelte/svelte/sortable';
import {
isFilterGroup,
Expand Down Expand Up @@ -123,7 +124,9 @@
: activeId === id && !isOverlay
? 'invisible'
: 'border'}"
style={id !== 'root' && !(activeId === id && !isOverlay) ? `${groupBg}; ${groupBorder}` : ''}
{@attach css(
id !== 'root' && !(activeId === id && !isOverlay) ? `${groupBg}; ${groupBorder}` : '',
)}
>
<div class="flex flex-row items-center gap-2 mb-1 w-full">
<div
Expand Down
8 changes: 2 additions & 6 deletions src/lib/components/tracking/GoogleTracking.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,11 @@
title="googleTagManger"
height="0"
width="0"
style="display:none;visibility:hidden"
class="hidden invisible"
></iframe>
</noscript>
<!-- End Google Tag Manager (noscript) -->
<div
data-testid="consentModal"
class="fixed"
style="left: 5%; bottom: 60px; z-index: 1000; width: 90%"
>
<div data-testid="consentModal" class="fixed left-[5%] bottom-[60px] z-[1000] w-[90%]">
<div class="bg-surface-50-950 p-4 rounded-container shadow-2xl">
<div class="flex flex-row justify-between items-center">
<div class="flex items center">
Expand Down
3 changes: 2 additions & 1 deletion src/lib/components/tree/RadioTreeNode.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { css } from '$lib/utilities/style';
import type { RadioNodeInterface } from '$lib/components/tree/types';
import RadioTreeNode from '$lib/components/tree/RadioTreeNode.svelte';

Expand All @@ -25,7 +26,7 @@
{#if !isRoot}
<i
class="fa-solid fa-angle-left fa-xl -rotate-45 self-center"
style="margin-left: {(index + 1) * 9}px;"
{@attach css({ 'margin-left': `${(index + 1) * 9}px` })}
></i>
{/if}
<input
Expand Down
29 changes: 29 additions & 0 deletions src/lib/server/csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export function withStyleNonce(csp: string | null): string | null {
if (!csp) return csp;

const nonce = /'nonce-([^']+)'/.exec(csp)?.[1];
if (!nonce) return csp;

return csp.replace(/(^|;\s*)style-src ([^;]*)/, (match, separator: string, sources: string) => {
// In dev, SvelteKit deliberately rewrites style-src to 'unsafe-inline' so Vite can inject
// stylesheets at runtime, and Vite only nonces those when the document carries a
// meta[property=csp-nonce] - which app.html does not. Adding a nonce here would make the
// browser ignore 'unsafe-inline' (CSP3) and leave the dev server completely unstyled.
if (sources.includes("'unsafe-inline'") || sources.includes(`'nonce-${nonce}'`)) return match;
return `${separator}style-src ${sources} 'nonce-${nonce}'`;
});
}

export function findStyleNonceProblem(csp: string | null): string | null {
if (!csp) return null;

const styleSrc = /(?:^|;\s*)style-src ([^;]*)/.exec(csp)?.[1];
if (!styleSrc) return 'the policy declares no style-src directive';

// Dev relaxes style-src to 'unsafe-inline', where the seed needs no nonce.
if (styleSrc.includes("'unsafe-inline'")) return null;

if (!styleSrc.includes("'nonce-")) return 'style-src carries no nonce';

return null;
}
36 changes: 36 additions & 0 deletions src/lib/utilities/style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Attachment } from 'svelte/attachments';

type Styles = string | Record<string, string | number>;
type Declaration = { property: string; value: string; priority: string };

let probe: HTMLElement | undefined;

function parse(styles: Styles): Declaration[] {
if (typeof styles !== 'string') {
return Object.entries(styles).map(([property, value]) => ({
property,
value: String(value),
priority: '',
}));
}
// Parse via the browser so values and !important survive the replay through setProperty.
probe ??= document.createElement('div');
probe.style.cssText = styles;
return Array.from(probe.style).map((property) => ({
property,
value: probe!.style.getPropertyValue(property),
priority: probe!.style.getPropertyPriority(property),
}));
}
Comment thread
JamesPeck marked this conversation as resolved.

export function css(styles: Styles): Attachment<HTMLElement> {
return (node) => {
const declarations = parse(styles);
for (const { property, value, priority } of declarations) {
node.style.setProperty(property, value, priority);
}
return () => {
for (const { property } of declarations) node.style.removeProperty(property);
};
};
}
41 changes: 41 additions & 0 deletions svelte.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

const extra = (name) => {
const sources = (process.env[name] ?? '')
.split(/\s+/)
.filter(Boolean)
.map((source) => source.replace(/^'(.*)'$/, '$1'));
const unsafe = sources.filter((source) => source.startsWith('unsafe-'));
if (unsafe.length) {
throw new Error(`${name} must not reintroduce ${unsafe.join(', ')} (ALS-9583)`);
}
return sources;
Comment thread
JamesPeck marked this conversation as resolved.
};

/** @type {import('@sveltejs/kit').Config} */
const config = {
extensions: ['.svelte'],
Expand All @@ -18,6 +30,35 @@ const config = {
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({ addressHeader: 'X-Forwarded-For' }),
csp: {
mode: 'nonce',
directives: {
'default-src': ['self'],
'base-uri': ['self'],
'object-src': ['none'],
'form-action': ['self'],
'frame-ancestors': ['none'],
'font-src': ['self', 'data:'],
'script-src': ['self', 'https://*.googletagmanager.com', ...extra('CSP_EXTRA_SCRIPT_SRC')],
'style-src': ['self', ...extra('CSP_EXTRA_STYLE_SRC')],
'style-src-attr': ['unsafe-inline'],
'img-src': [
'self',
'data:',
'blob:',
'https://*.google-analytics.com',
'https://*.googletagmanager.com',
...extra('CSP_EXTRA_IMG_SRC'),
],
'connect-src': [
'self',
'https://*.google-analytics.com',
'https://*.analytics.google.com',
'https://*.googletagmanager.com',
...extra('CSP_EXTRA_CONNECT_SRC'),
],
},
},
},
};
export default config;
68 changes: 68 additions & 0 deletions tests/unit/Csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';

import { findStyleNonceProblem, withStyleNonce } from '$lib/server/csp';

const NONCE = 'abc123==';
const prod = (styleSrc: string) =>
`default-src 'self'; script-src 'self' 'nonce-${NONCE}'; style-src ${styleSrc}; base-uri 'self'`;

describe('withStyleNonce', () => {
it("copies the script nonce into style-src so app.html's Plotly seed is allowed", () => {
expect(withStyleNonce(prod("'self'"))).toContain(`style-src 'self' 'nonce-${NONCE}'`);
});

it('leaves every other directive untouched', () => {
const result = withStyleNonce(prod("'self'")) ?? '';
expect(result).toContain("default-src 'self'");
expect(result).toContain("base-uri 'self'");
expect(result).toContain(`script-src 'self' 'nonce-${NONCE}'`);
});

it('does not disturb style-src-attr, which has no nonce mechanism', () => {
const csp = `${prod("'self'")}; style-src-attr 'unsafe-inline'`;
expect(withStyleNonce(csp)).toContain("style-src-attr 'unsafe-inline'");
});

it("makes no change in dev, where style-src already carries 'unsafe-inline'", () => {
const dev = prod("'self' 'unsafe-inline'");
expect(withStyleNonce(dev)).toBe(dev);
});

it('is idempotent', () => {
const once = withStyleNonce(prod("'self'"));
expect(withStyleNonce(once)).toBe(once);
});

it('returns the policy unchanged when no nonce is present', () => {
const csp = "default-src 'none'; style-src 'self'";
expect(withStyleNonce(csp)).toBe(csp);
});

it('passes through a missing header', () => {
expect(withStyleNonce(null)).toBeNull();
});
});

describe('findStyleNonceProblem', () => {
it('accepts the production policy once the nonce has been copied across', () => {
expect(findStyleNonceProblem(withStyleNonce(prod("'self'")))).toBeNull();
});

it("accepts dev, where style-src is deliberately relaxed to 'unsafe-inline'", () => {
expect(findStyleNonceProblem(prod("'self' 'unsafe-inline'"))).toBeNull();
});

it('reports a policy with no style-src directive', () => {
const csp = `default-src 'self'; script-src 'self' 'nonce-${NONCE}'`;
expect(findStyleNonceProblem(csp)).toMatch(/no style-src/);
});

it('reports a style-src that never received a nonce', () => {
const csp = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'";
expect(findStyleNonceProblem(csp)).toMatch(/no nonce/);
});

it('ignores responses that carry no policy', () => {
expect(findStyleNonceProblem(null)).toBeNull();
});
});
Loading
Loading