From 68cbf8a88642d8313f151a274fb5c24dee6a2762 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Tue, 16 Jun 2026 14:20:21 +0700 Subject: [PATCH] Apply fixes Signed-off-by: Artem Savchenko --- .../server/packages/core/src/__tests__/ssrf.test.ts | 10 ++++++++++ foundations/server/packages/core/src/ssrf.ts | 11 ++++++++--- server/front/src/index.ts | 5 ++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/foundations/server/packages/core/src/__tests__/ssrf.test.ts b/foundations/server/packages/core/src/__tests__/ssrf.test.ts index b661a335f6..ef4bb1e296 100644 --- a/foundations/server/packages/core/src/__tests__/ssrf.test.ts +++ b/foundations/server/packages/core/src/__tests__/ssrf.test.ts @@ -98,6 +98,16 @@ describe('isBlockedHost', () => { it('handles bracketed and trailing-dot hostnames', () => { expect(isBlockedHost('[::1]')).toBe(true) expect(isBlockedHost('localhost.')).toBe(true) + expect(isBlockedHost('localhost...')).toBe(true) + expect(isBlockedHost('127.0.0.1.')).toBe(true) + expect(isBlockedHost('example.com.')).toBe(false) + }) + + it('strips trailing dots in linear time on hostile input (ReDoS regression)', () => { + const hostile = '.'.repeat(100000) + 'x' + const start = Date.now() + expect(isBlockedHost(hostile)).toBe(false) + expect(Date.now() - start).toBeLessThan(1000) }) }) diff --git a/foundations/server/packages/core/src/ssrf.ts b/foundations/server/packages/core/src/ssrf.ts index efc71a1a02..e1e5a5dc81 100644 --- a/foundations/server/packages/core/src/ssrf.ts +++ b/foundations/server/packages/core/src/ssrf.ts @@ -62,9 +62,14 @@ const DEFAULT_ALLOWED_PROTOCOLS = ['http:', 'https:'] function normalizeHostnameForChecks (hostname: string): string { // URL.hostname is already punycode-normalized by WHATWG URL for IDNs. // Keep it lowercase for comparisons. - const trimmed = hostname.trim().toLowerCase().replace(/\.+$/, '') - if (trimmed.startsWith('[') && trimmed.endsWith(']')) return trimmed.slice(1, -1) - return trimmed + let host = hostname.trim().toLowerCase() + // Strip trailing dots with a linear scan rather than /\.+$/, which backtracks + // in polynomial time on hostile input (e.g. many '.' followed by a non-dot). + let end = host.length + while (end > 0 && host.charCodeAt(end - 1) === 0x2e /* '.' */) end-- + host = host.slice(0, end) + if (host.startsWith('[') && host.endsWith(']')) return host.slice(1, -1) + return host } // Expands an IPv6 literal (possibly compressed, zone-suffixed, or carrying an diff --git a/server/front/src/index.ts b/server/front/src/index.ts index 4418fa91b5..d1b3deca5a 100644 --- a/server/front/src/index.ts +++ b/server/front/src/index.ts @@ -688,8 +688,11 @@ export function start ( return cookie !== undefined ? { lookup, headers: { Cookie: cookie } } : { lookup } } catch (err) { if (err instanceof SsrfError) { + // Log the detail (incl. the offending url) server-side, but never reflect it + // back: res.send(string) is served as text/html, so echoing err.message would + // be a reflected-XSS / info-leak sink. err.code is a fixed enum, safe to return. ctx.warn('import blocked', { code: err.code, url }) - res.status(400).send(err.message) + res.status(400).send(`Import URL rejected: ${err.code}`) return undefined } throw err