fix(dashboard): stop ERR_CONTENT_DECODING_FAILED on auth/proxy (#779)

* fix(dashboard): prevent ERR_CONTENT_DECODING_FAILED on API proxy

Node fetch decompresses gzip/br upstream bodies while leaving
Content-Encoding set, so browsers fail auth/proxy responses with
net::ERR_CONTENT_DECODING_FAILED. Request identity encoding and strip
encoding headers from proxied responses. Also allow data: fonts in CSP
and skip UserJot on self-hosted.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): preserve Set-Cookie and use Jest for proxy tests

Copy Set-Cookie via getSetCookie/append so multi-cookie auth responses
are not collapsed on Expires commas. Convert proxy tests to Jest and
add CSP domain normalization coverage.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-23 17:43:52 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent b599f1a017
commit 2ceafcc6d1
7 changed files with 236 additions and 7 deletions
@@ -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',
+22
View File
@@ -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'
]);
});
});
+18 -3
View File
@@ -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<string, string[]> {
@@ -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}');
});
});
@@ -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<Response> {
const url = new URL(request.url);
const apiBase = resolveApiUpstreamBase();
@@ -74,6 +121,9 @@ export async function proxyRequestToApi(request: Request): Promise<Response> {
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<Response> {
init.body = await request.arrayBuffer();
}
return fetch(upstreamUrl, init);
const upstreamResponse = await fetch(upstreamUrl, init);
return buildProxiedApiResponse(upstreamResponse);
}
@@ -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);
}
+3 -2
View File
@@ -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',