diff --git a/apps/dashboard/src/lib/utils/csp-domains.js b/apps/dashboard/src/lib/utils/csp-domains.js index 2f854ee0..7e6b1599 100644 --- a/apps/dashboard/src/lib/utils/csp-domains.js +++ b/apps/dashboard/src/lib/utils/csp-domains.js @@ -7,6 +7,7 @@ const saasDefaults = { 'https://assets.cdn.clsrio.com', 'https://embed.classroomio.com', 'https://cdnjs.cloudflare.com', + 'https://cdn.userjot.com', 'https://*.posthog.com', 'https://*.senja.io', 'https://umami.hz.oncws.com', diff --git a/apps/dashboard/src/lib/utils/csp.test.ts b/apps/dashboard/src/lib/utils/csp.test.ts new file mode 100644 index 00000000..3df7fc87 --- /dev/null +++ b/apps/dashboard/src/lib/utils/csp.test.ts @@ -0,0 +1,22 @@ +import { parseCspDomains } from './csp'; + +describe('parseCspDomains', () => { + it('returns an empty list for missing or blank input', () => { + expect(parseCspDomains(undefined)).toEqual([]); + expect(parseCspDomains('')).toEqual([]); + expect(parseCspDomains(' ')).toEqual([]); + expect(parseCspDomains(', ,')).toEqual([]); + }); + + it('preserves special CSP source tokens and quoted self', () => { + expect(parseCspDomains("data:, blob:, self, 'self'")).toEqual(['data:', 'blob:', "'self'", "'self'"]); + }); + + it('keeps explicit http(s) URLs and prefixes bare domains', () => { + expect(parseCspDomains(' https://cdn.example.com , http://localhost:9000 , fonts.gstatic.com ')).toEqual([ + 'https://cdn.example.com', + 'http://localhost:9000', + 'https://fonts.gstatic.com' + ]); + }); +}); diff --git a/apps/dashboard/src/lib/utils/csp.ts b/apps/dashboard/src/lib/utils/csp.ts index 6b849f45..ec823a34 100644 --- a/apps/dashboard/src/lib/utils/csp.ts +++ b/apps/dashboard/src/lib/utils/csp.ts @@ -1,10 +1,25 @@ -function parseCspDomains(value: string | undefined): string[] { +/** Normalize a comma-separated CSP domain/env list into valid CSP source values. */ +export function parseCspDomains(value: string | undefined): string[] { if (!value) return []; + return value .split(',') - .map((d) => d.trim()) + .map((domain) => domain.trim()) .filter(Boolean) - .map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`)); + .map((domain) => { + if ( + domain === 'data:' || + domain === 'blob:' || + domain === "'self'" || + domain === 'self' || + domain.startsWith('http://') || + domain.startsWith('https://') + ) { + return domain === 'self' ? "'self'" : domain; + } + + return `https://${domain}`; + }); } function buildCspExtensions(): Record { diff --git a/apps/dashboard/src/lib/utils/proxy-api-request.test.ts b/apps/dashboard/src/lib/utils/proxy-api-request.test.ts new file mode 100644 index 00000000..f37df0c0 --- /dev/null +++ b/apps/dashboard/src/lib/utils/proxy-api-request.test.ts @@ -0,0 +1,134 @@ +import { buildProxiedApiResponse, proxyRequestToApi, shouldForwardToApi } from './proxy-api-request'; + +describe('shouldForwardToApi', () => { + it('forwards /proxy and /api/auth paths', () => { + expect(shouldForwardToApi('/proxy')).toBe(true); + expect(shouldForwardToApi('/proxy/account')).toBe(true); + expect(shouldForwardToApi('/api/auth')).toBe(true); + expect(shouldForwardToApi('/api/auth/get-session')).toBe(true); + }); + + it('does not forward unrelated paths', () => { + expect(shouldForwardToApi('/login')).toBe(false); + expect(shouldForwardToApi('/api/csp-report')).toBe(false); + }); +}); + +describe('buildProxiedApiResponse', () => { + it('strips content-encoding and content-length so the browser does not re-decode', async () => { + const upstream = new Response('{"user":null}', { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-encoding': 'gzip', + 'content-length': '13', + 'transfer-encoding': 'chunked' + } + }); + + const proxied = buildProxiedApiResponse(upstream); + + expect(proxied.status).toBe(200); + expect(proxied.headers.get('content-type')).toBe('application/json'); + expect(proxied.headers.get('content-encoding')).toBeNull(); + expect(proxied.headers.get('content-length')).toBeNull(); + expect(proxied.headers.get('transfer-encoding')).toBeNull(); + expect(await proxied.text()).toBe('{"user":null}'); + }); + + it('preserves multiple Set-Cookie headers without collapsing Expires commas', async () => { + const upstreamHeaders = new Headers({ + 'content-type': 'application/json', + 'content-encoding': 'gzip' + }); + upstreamHeaders.append( + 'set-cookie', + 'better-auth.session_token=abc; Path=/; HttpOnly; Expires=Wed, 21 Oct 2026 07:28:00 GMT' + ); + upstreamHeaders.append( + 'set-cookie', + 'better-auth.csrf_token=xyz; Path=/; HttpOnly; Expires=Thu, 22 Oct 2026 07:28:00 GMT' + ); + + const proxied = buildProxiedApiResponse( + new Response('{"ok":true}', { + status: 200, + headers: upstreamHeaders + }) + ); + + expect(proxied.headers.getSetCookie()).toEqual([ + 'better-auth.session_token=abc; Path=/; HttpOnly; Expires=Wed, 21 Oct 2026 07:28:00 GMT', + 'better-auth.csrf_token=xyz; Path=/; HttpOnly; Expires=Thu, 22 Oct 2026 07:28:00 GMT' + ]); + expect(proxied.headers.get('content-encoding')).toBeNull(); + expect(await proxied.text()).toBe('{"ok":true}'); + }); +}); + +describe('proxyRequestToApi', () => { + const originalPrivateServerUrl = process.env.PRIVATE_SERVER_URL; + const originalFetch = global.fetch; + + afterEach(() => { + if (originalPrivateServerUrl === undefined) { + delete process.env.PRIVATE_SERVER_URL; + } else { + process.env.PRIVATE_SERVER_URL = originalPrivateServerUrl; + } + + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('returns 502 when PRIVATE_SERVER_URL is missing', async () => { + delete process.env.PRIVATE_SERVER_URL; + + const response = await proxyRequestToApi(new Request('https://app.example/api/auth/get-session')); + + expect(response.status).toBe(502); + expect(await response.text()).toBe('API upstream not configured'); + }); + + it('requests identity encoding and returns a stripped response', async () => { + process.env.PRIVATE_SERVER_URL = 'http://api.internal:3081'; + + const fetchMock = jest.fn().mockResolvedValue( + new Response('{"session":null}', { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-encoding': 'gzip', + 'content-length': '16' + } + }) + ); + global.fetch = fetchMock as typeof fetch; + + const response = await proxyRequestToApi( + new Request('https://dashboard.example/api/auth/get-session', { + headers: { + accept: 'application/json', + 'accept-encoding': 'gzip, deflate, br', + cookie: 'better-auth.session_token=test' + } + }) + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [upstreamUrl, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(String(upstreamUrl)).toBe('http://api.internal:3081/api/auth/get-session'); + expect(init.method).toBe('GET'); + + const upstreamHeaders = new Headers(init.headers); + expect(upstreamHeaders.get('accept-encoding')).toBe('identity'); + expect(upstreamHeaders.get('host')).toBe('api.internal:3081'); + expect(upstreamHeaders.get('x-forwarded-host')).toBe('dashboard.example'); + expect(upstreamHeaders.get('cookie')).toBe('better-auth.session_token=test'); + + expect(response.status).toBe(200); + expect(response.headers.get('content-encoding')).toBeNull(); + expect(response.headers.get('content-length')).toBeNull(); + expect(await response.text()).toBe('{"session":null}'); + }); +}); diff --git a/apps/dashboard/src/lib/utils/proxy-api-request.ts b/apps/dashboard/src/lib/utils/proxy-api-request.ts index 7d168ad5..a0132bb6 100644 --- a/apps/dashboard/src/lib/utils/proxy-api-request.ts +++ b/apps/dashboard/src/lib/utils/proxy-api-request.ts @@ -10,6 +10,9 @@ const PROXY_PREFIX = '/proxy'; const AUTH_PREFIX = '/api/auth'; +/** Hop-by-hop / encoding headers that must not be forwarded after Node fetch decompresses. */ +const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length', 'transfer-encoding'] as const; + export function shouldForwardToApi(pathname: string): boolean { return ( pathname === PROXY_PREFIX || @@ -57,6 +60,50 @@ function resolveBrowserForwardedHost(request: Request, url: URL): string { return raw; } +function isStrippedResponseHeader(name: string): boolean { + return (RESPONSE_HEADERS_TO_STRIP as readonly string[]).includes(name); +} + +/** + * Node's `fetch` always decompresses gzip/br bodies while often leaving + * `Content-Encoding` on the Response. Returning that Response to the browser + * (adapter-node) causes `net::ERR_CONTENT_DECODING_FAILED` on auth/proxy calls. + * + * Ask the upstream for an identity body and strip encoding length headers so + * the client receives a plain payload that matches the headers. + * + * Set-Cookie must be copied via `getSetCookie()` + `append` — never through + * `Headers.get('set-cookie')` / a naive `new Headers(upstream.headers)` path + * that can collapse multiple cookies into one comma-joined value (Expires + * attributes contain commas, so browsers may drop session/CSRF cookies). + */ +export function buildProxiedApiResponse(upstream: Response): Response { + const headers = new Headers(); + + for (const [name, value] of upstream.headers.entries()) { + if (name === 'set-cookie' || isStrippedResponseHeader(name)) { + continue; + } + + headers.set(name, value); + } + + const setCookies = + typeof upstream.headers.getSetCookie === 'function' + ? upstream.headers.getSetCookie() + : [...upstream.headers.entries()].filter(([name]) => name === 'set-cookie').map(([, value]) => value); + + for (const cookie of setCookies) { + headers.append('set-cookie', cookie); + } + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers + }); +} + export async function proxyRequestToApi(request: Request): Promise { const url = new URL(request.url); const apiBase = resolveApiUpstreamBase(); @@ -74,6 +121,9 @@ export async function proxyRequestToApi(request: Request): Promise { upstreamHeaders.set('host', upstreamUrl.host); upstreamHeaders.set('x-forwarded-host', originalHost); upstreamHeaders.set('x-forwarded-proto', url.protocol.replace(':', '')); + // Prevent compressed upstream responses: Node decompresses automatically but + // can leave Content-Encoding set, which breaks the browser. + upstreamHeaders.set('accept-encoding', 'identity'); const forwardedFor = request.headers.get('cf-connecting-ip') ?? request.headers.get('x-forwarded-for'); if (forwardedFor) { @@ -90,5 +140,7 @@ export async function proxyRequestToApi(request: Request): Promise { init.body = await request.arrayBuffer(); } - return fetch(upstreamUrl, init); + const upstreamResponse = await fetch(upstreamUrl, init); + + return buildProxiedApiResponse(upstreamResponse); } diff --git a/apps/dashboard/src/lib/utils/services/userjot/index.ts b/apps/dashboard/src/lib/utils/services/userjot/index.ts index 821b45db..05b1f4df 100644 --- a/apps/dashboard/src/lib/utils/services/userjot/index.ts +++ b/apps/dashboard/src/lib/utils/services/userjot/index.ts @@ -1,4 +1,5 @@ import { dev } from '$app/environment'; +import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; import { get } from 'svelte/store'; import { globalStore } from '$lib/utils/store/app'; @@ -8,6 +9,7 @@ let isInitialized = false; function isWidgetAllowed(): boolean { if (dev) return false; + if (PUBLIC_IS_SELFHOSTED === 'true') return false; if (get(globalStore).isOrgSite) return false; return true; @@ -30,7 +32,9 @@ function ensureSdkLoaded(): void { script.type = 'module'; script.async = true; script.src = 'https://cdn.userjot.com/sdk/v2/uj.js'; - const nonce = document.querySelector('script[nonce]')?.getAttribute('nonce'); + // Browsers hide the nonce attribute in the DOM; read via the IDL property. + const nonceSource = document.querySelector('script[nonce]') as HTMLScriptElement | null; + const nonce = nonceSource?.nonce || nonceSource?.getAttribute('nonce'); if (nonce) script.setAttribute('nonce', nonce); document.head.appendChild(script); } diff --git a/apps/dashboard/svelte.config.js b/apps/dashboard/svelte.config.js index a937ec1f..662443bd 100644 --- a/apps/dashboard/svelte.config.js +++ b/apps/dashboard/svelte.config.js @@ -38,7 +38,8 @@ const config = { 'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'], 'style-src': ['self', 'unsafe-inline', ...csp.styleSrc], 'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc], - 'font-src': ['self', ...csp.fontSrc], + // data: covers inlined woff2 (e.g. PDF.js / icon fonts); file fonts use 'self' + 'font-src': ['self', 'data:', ...csp.fontSrc], 'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'], 'media-src': [ 'self', @@ -70,7 +71,7 @@ const config = { 'script-src': ['self', ...csp.scriptSrc, 'unsafe-hashes', 'unsafe-eval'], 'style-src': ['self', 'unsafe-inline', ...csp.styleSrc], 'style-src-elem': ['self', 'unsafe-inline', ...csp.styleSrc], - 'font-src': ['self', ...csp.fontSrc], + 'font-src': ['self', 'data:', ...csp.fontSrc], 'img-src': ['self', 'data:', ...csp.mediaSrc, 'blob:', 'http://localhost:9000'], 'media-src': [ 'self',