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:
co-authored by
Rotimi Best
Cursor Agent
parent
b599f1a017
commit
2ceafcc6d1
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user