From 051a9bb5a1f9dfdde584b3436d4801bd64402538 Mon Sep 17 00:00:00 2001 From: UncleDoomVSSP Date: Wed, 7 Oct 2026 01:45:23 +0100 Subject: [PATCH 1/3] feat(calendar): groundwork for CalDAV calendar integration Preparation for a CalDAV client integration in pod-calendar, with no user-visible change for existing deployments. - Add @hcengineering/safe-fetch, an SSRF-guarded fetch for services that request user-supplied URLs: DNS resolution with blocked private and reserved ranges, pinned connections, per-hop redirect validation, Authorization stripping across origins, timeouts and body size caps. Unit tested with stubbed DNS and local HTTP servers. - Make the Google Calendar module in pod-calendar optional: the service starts without Credentials and WATCH_URL, logs that Google is disabled, keeps /event available and answers the Google endpoints with 501. - Add the caldav-calendar integration kind and the CalDavCalendar mixin on ExternalCalendar, so provider-specific code can tell CalDAV calendars apart from Google ones. No data migration is needed. - Restrict the Google sync and outbound paths to calendars without that mixin. Signed-off-by: UncleDoomVSSP Co-Authored-By: Claude Fable 5.1 --- common/config/rush/pnpm-lock.yaml | 55 +++++ models/calendar/src/index.ts | 18 ++ packages/safe-fetch/.eslintrc.js | 7 + packages/safe-fetch/README.md | 24 +++ packages/safe-fetch/config/rig.json | 4 + packages/safe-fetch/jest.config.js | 7 + packages/safe-fetch/package.json | 55 +++++ .../safe-fetch/src/__tests__/fetch.test.ts | 202 ++++++++++++++++++ .../safe-fetch/src/__tests__/ranges.test.ts | 105 +++++++++ .../safe-fetch/src/__tests__/resolve.test.ts | 75 +++++++ .../safe-fetch/src/__tests__/safe-url.test.ts | 72 +++++++ packages/safe-fetch/src/fetch.ts | 188 ++++++++++++++++ packages/safe-fetch/src/index.ts | 16 ++ packages/safe-fetch/src/ranges.ts | 135 ++++++++++++ packages/safe-fetch/src/resolve.ts | 52 +++++ packages/safe-fetch/src/safe-fetch-types.ts | 56 +++++ packages/safe-fetch/src/safe-url.ts | 64 ++++++ packages/safe-fetch/tsconfig.json | 12 ++ plugins/calendar/src/index.ts | 26 ++- rush.json | 5 + services/calendar/pod-calendar/src/config.ts | 43 +++- services/calendar/pod-calendar/src/main.ts | 41 +++- .../pod-calendar/src/outcomingClient.ts | 2 + services/calendar/pod-calendar/src/sync.ts | 5 +- services/calendar/pod-calendar/src/utils.ts | 4 +- .../pod-calendar/src/workspaceClient.ts | 5 +- 26 files changed, 1253 insertions(+), 25 deletions(-) create mode 100644 packages/safe-fetch/.eslintrc.js create mode 100644 packages/safe-fetch/README.md create mode 100644 packages/safe-fetch/config/rig.json create mode 100644 packages/safe-fetch/jest.config.js create mode 100644 packages/safe-fetch/package.json create mode 100644 packages/safe-fetch/src/__tests__/fetch.test.ts create mode 100644 packages/safe-fetch/src/__tests__/ranges.test.ts create mode 100644 packages/safe-fetch/src/__tests__/resolve.test.ts create mode 100644 packages/safe-fetch/src/__tests__/safe-url.test.ts create mode 100644 packages/safe-fetch/src/fetch.ts create mode 100644 packages/safe-fetch/src/index.ts create mode 100644 packages/safe-fetch/src/ranges.ts create mode 100644 packages/safe-fetch/src/resolve.ts create mode 100644 packages/safe-fetch/src/safe-fetch-types.ts create mode 100644 packages/safe-fetch/src/safe-url.ts create mode 100644 packages/safe-fetch/tsconfig.json diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b091421b53..3fd9e68583 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -14949,6 +14949,61 @@ importers: specifier: ^5.9.3 version: 5.9.3 + ../../packages/safe-fetch: + dependencies: + ipaddr.js: + specifier: ^2.2.0 + version: 2.2.0 + undici: + specifier: ^7.18.2 + version: 7.18.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: workspace:^0.7.21 + version: link:../../foundations/utils/packages/platform-rig + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^24.13.3 + version: 24.13.6 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + esbuild: + specifier: ^0.25.10 + version: 0.25.12 + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.13.6)(ts-node@10.9.2(@types/node@24.13.6)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@24.13.6))(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + ../../packages/theme: dependencies: '@hcengineering/analytics': diff --git a/models/calendar/src/index.ts b/models/calendar/src/index.ts index 887a29048f..d3db2013ae 100644 --- a/models/calendar/src/index.ts +++ b/models/calendar/src/index.ts @@ -18,6 +18,7 @@ import { type AccessLevel, calendarId, type PrimaryCalendar, + type CalDavCalendar, type Calendar, type CalendarEventPresenter, type Event, @@ -45,6 +46,7 @@ import { import { ArrOf, Collection, + Hidden, Index, Mixin, Model, @@ -98,6 +100,21 @@ export class TExternalCalendar extends TCalendar implements ExternalCalendar { externalUser!: string } +@Mixin(calendar.mixin.CalDavCalendar, calendar.class.ExternalCalendar) +export class TCalDavCalendar extends TExternalCalendar implements CalDavCalendar { + @Prop(TypeString(), calendar.string.Account) + @Hidden() + accountKey!: string + + @Prop(TypeString(), calendar.string.Calendar) + @Hidden() + href!: string + + @Prop(TypeString(), calendar.string.Calendar) + @Hidden() + ctag?: string +} + @Model(calendar.class.Event, core.class.AttachedDoc, DOMAIN_EVENT) @UX(calendar.string.Event, calendar.icon.Calendar) export class TEvent extends TAttachedDoc implements Event { @@ -200,6 +217,7 @@ export function createModel (builder: Builder): void { builder.createModel( TCalendar, TExternalCalendar, + TCalDavCalendar, TReccuringEvent, TReccuringInstance, TEvent, diff --git a/packages/safe-fetch/.eslintrc.js b/packages/safe-fetch/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/packages/safe-fetch/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/safe-fetch/README.md b/packages/safe-fetch/README.md new file mode 100644 index 0000000000..3d79f8c2ff --- /dev/null +++ b/packages/safe-fetch/README.md @@ -0,0 +1,24 @@ +# @hcengineering/safe-fetch + +A `fetch` wrapper for services that request user-supplied URLs (link previews, CalDAV servers, webhooks). + +It blocks server-side request forgery by: + +- allowing only `https:` (and `http:` when explicitly enabled); +- resolving every A and AAAA record of the hostname and rejecting the request when any address falls in a + private, loopback, link-local, multicast or otherwise reserved range; +- pinning the TCP connection to the addresses that were checked, so a DNS answer cannot change between the + check and the connection; +- validating every redirect hop the same way, dropping `Authorization` when the origin changes and switching + to `GET` where the HTTP specification requires it; +- applying a timeout across the whole redirect chain and a cap on the response body size. + +Operators can allow specific hosts or CIDR ranges that would otherwise be blocked, for example a CalDAV server +on a private network. + +```ts +import { createSafeFetch } from '@hcengineering/safe-fetch' + +const fetch = createSafeFetch({ allowlist: ['caldav.internal.example', '10.20.0.0/16'] }) +const res = await fetch('https://caldav.internal.example/.well-known/caldav', { redirect: 'manual' }) +``` diff --git a/packages/safe-fetch/config/rig.json b/packages/safe-fetch/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/packages/safe-fetch/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/packages/safe-fetch/jest.config.js b/packages/safe-fetch/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/safe-fetch/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/safe-fetch/package.json b/packages/safe-fetch/package.json new file mode 100644 index 0000000000..3a2bdaca21 --- /dev/null +++ b/packages/safe-fetch/package.json @@ -0,0 +1,55 @@ +{ + "name": "@hcengineering/safe-fetch", + "version": "0.7.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "workspace:^0.7.21", + "@types/jest": "^29.5.5", + "@types/node": "^24.13.3", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "esbuild": "^0.25.10", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.6.2", + "ts-jest": "^29.1.1", + "typescript": "^5.9.3" + }, + "dependencies": { + "ipaddr.js": "^2.2.0", + "undici": "^7.18.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hcengineering/platform.git" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/packages/safe-fetch/src/__tests__/fetch.test.ts b/packages/safe-fetch/src/__tests__/fetch.test.ts new file mode 100644 index 0000000000..c7baab5fac --- /dev/null +++ b/packages/safe-fetch/src/__tests__/fetch.test.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: EPL-2.0 + +import http from 'http' +import { type AddressInfo } from 'net' +import { createSafeFetch } from '../fetch' +import { type Lookup, SafeFetchError } from '../safe-fetch-types' + +interface Seen { + method: string + path: string + authorization?: string + body: string +} + +interface TestServer { + server: http.Server + port: number + seen: Seen[] +} + +async function startServer ( + handle: (req: http.IncomingMessage, res: http.ServerResponse, seen: Seen) => void +): Promise { + const seen: Seen[] = [] + const server = http.createServer((req, res) => { + let body = '' + req.on('data', (chunk: Buffer) => { + body += chunk.toString() + }) + req.on('end', () => { + const entry: Seen = { + method: req.method ?? '', + path: req.url ?? '', + authorization: req.headers.authorization, + body + } + seen.push(entry) + handle(req, res, entry) + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return { server, port: (server.address() as AddressInfo).port, seen } +} + +async function codeOf (promise: Promise): Promise { + try { + await promise + } catch (err) { + if (err instanceof SafeFetchError) return err.code + throw err + } + return undefined +} + +describe('createSafeFetch', () => { + let primary: TestServer + let secondary: TestServer + // Names resolve only through the injected lookup, never through system DNS. + const lookup: Lookup = async (hostname) => { + if (hostname === 'app.test' || hostname === 'other.test') return [{ address: '127.0.0.1', family: 4 }] + if (hostname === 'evil.test') return [{ address: '169.254.169.254', family: 4 }] + throw new Error('ENOTFOUND') + } + const base = { allowHttp: true, allowlist: ['127.0.0.1/32'], lookup, timeoutMs: 2000 } + + beforeAll(async () => { + primary = await startServer((req, res, seen) => { + const url = new URL(seen.path, 'http://app.test') + switch (url.pathname) { + case '/ok': + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ ok: true, auth: seen.authorization ?? null })) + return + case '/redirect-same': + res.writeHead(302, { location: '/ok' }) + res.end() + return + case '/redirect-other': + res.writeHead(302, { location: `http://other.test:${secondary.port}/ok` }) + res.end() + return + case '/redirect-private': + res.writeHead(302, { location: 'http://10.0.0.1/' }) + res.end() + return + case '/redirect-evil': + res.writeHead(302, { location: 'http://evil.test/latest/meta-data/' }) + res.end() + return + case '/see-other': + res.writeHead(303, { location: '/ok' }) + res.end() + return + case '/loop': + res.writeHead(302, { location: '/loop' }) + res.end() + return + case '/slow': + setTimeout(() => res.end('late'), 500) + return + case '/big-declared': + res.setHeader('content-length', '1500') + res.end(Buffer.alloc(1500, 'a')) + return + case '/big-chunked': + res.write(Buffer.alloc(600, 'a')) + res.write(Buffer.alloc(600, 'b')) + res.end() + return + default: + res.writeHead(404) + res.end() + } + }) + secondary = await startServer((_req, res, seen) => { + res.end(JSON.stringify({ host: 'other', auth: seen.authorization ?? null })) + }) + }) + + afterAll(async () => { + await new Promise((resolve) => primary.server.close(resolve)) + await new Promise((resolve) => secondary.server.close(resolve)) + }) + + const url = (path: string, port?: number): string => `http://app.test:${port ?? primary.port}${path}` + + it('fetches through the pinned address for a name only the injected lookup knows', async () => { + const fetch = createSafeFetch(base) + const res = await fetch(url('/ok'), { headers: { authorization: 'Bearer t' } }) + expect(res.status).toBe(200) + expect(res.url).toBe(url('/ok')) + expect(await res.json()).toEqual({ ok: true, auth: 'Bearer t' }) + }) + + it('refuses hosts and protocols before any request is made', async () => { + const fetch = createSafeFetch(base) + expect(await codeOf(fetch(`https://localhost:${primary.port}/ok`))).toBe('BLOCKED_HOST') + expect(await codeOf(fetch(`ftp://app.test:${primary.port}/ok`))).toBe('INVALID_PROTOCOL') + expect(await codeOf(fetch(url('/ok', 1)))).toBe('FETCH_FAILED') + expect(await codeOf(createSafeFetch({ ...base, allowlist: [] })(url('/ok')))).toBe('BLOCKED_ADDRESS') + expect(primary.seen.filter((s) => s.path === '/ok').length).toBe(1) + }) + + it('follows a same-origin redirect and keeps the authorization header', async () => { + const fetch = createSafeFetch(base) + const res = await fetch(url('/redirect-same'), { headers: { authorization: 'Bearer t' } }) + expect(res.status).toBe(200) + expect(res.url).toBe(url('/ok')) + expect(await res.json()).toEqual({ ok: true, auth: 'Bearer t' }) + }) + + it('drops the authorization header on a cross-origin redirect', async () => { + const fetch = createSafeFetch(base) + const res = await fetch(url('/redirect-other'), { headers: { authorization: 'Bearer t' } }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ host: 'other', auth: null }) + }) + + it('validates every redirect hop against the blocked ranges and DNS', async () => { + const fetch = createSafeFetch(base) + expect(await codeOf(fetch(url('/redirect-private')))).toBe('BLOCKED_ADDRESS') + expect(await codeOf(fetch(url('/redirect-evil')))).toBe('BLOCKED_ADDRESS') + }) + + it('switches to GET on 303 and drops the body', async () => { + const fetch = createSafeFetch(base) + const res = await fetch(url('/see-other'), { method: 'POST', body: 'payload' }) + expect(res.status).toBe(200) + const last = primary.seen[primary.seen.length - 1] + expect(last.method).toBe('GET') + expect(last.path).toBe('/ok') + expect(last.body).toBe('') + }) + + it('returns the 3xx untouched in manual mode and throws in error mode', async () => { + const fetch = createSafeFetch(base) + const manual = await fetch(url('/redirect-same'), { redirect: 'manual' }) + expect(manual.status).toBe(302) + expect(manual.headers.get('location')).toBe('/ok') + expect(await codeOf(fetch(url('/redirect-same'), { redirect: 'error' }))).toBe('REDIRECT_NOT_ALLOWED') + }) + + it('stops after too many redirects', async () => { + const fetch = createSafeFetch({ ...base, maxRedirects: 3 }) + expect(await codeOf(fetch(url('/loop')))).toBe('TOO_MANY_REDIRECTS') + }) + + it('times out across the whole request', async () => { + const fetch = createSafeFetch({ ...base, timeoutMs: 100 }) + expect(await codeOf(fetch(url('/slow')))).toBe('TIMEOUT') + }) + + it('caps the response body by declared length and while streaming', async () => { + const fetch = createSafeFetch({ ...base, maxBodyBytes: 1000 }) + expect(await codeOf(fetch(url('/big-declared')))).toBe('BODY_TOO_LARGE') + const streamed = await fetch(url('/big-chunked')) + expect(streamed.status).toBe(200) + expect(await codeOf(streamed.text())).toBe('BODY_TOO_LARGE') + const fine = await createSafeFetch({ ...base, maxBodyBytes: 2000 })(url('/big-chunked')) + expect((await fine.text()).length).toBe(1200) + }) +}) diff --git a/packages/safe-fetch/src/__tests__/ranges.test.ts b/packages/safe-fetch/src/__tests__/ranges.test.ts new file mode 100644 index 0000000000..18c8e740d2 --- /dev/null +++ b/packages/safe-fetch/src/__tests__/ranges.test.ts @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { isAllowlistedAddress, isBlockedAddress } from '../ranges' + +describe('isBlockedAddress', () => { + const blocked = [ + '0.0.0.0', + '0.1.2.3', + '10.0.0.1', + '10.255.255.255', + '100.64.0.1', + '100.127.255.254', + '127.0.0.1', + '127.255.255.255', + '169.254.169.254', + '172.16.0.1', + '172.31.255.254', + '192.0.0.1', + '192.0.2.1', + '192.168.1.1', + '198.18.0.1', + '198.19.255.254', + '198.51.100.1', + '203.0.113.1', + '224.0.0.1', + '239.255.255.255', + '240.0.0.1', + '255.255.255.255', + '::', + '::1', + '::ffff:127.0.0.1', + '::ffff:7f00:1', + '::ffff:10.1.2.3', + '::10.0.0.1', + '64:ff9b::10.0.0.1', + '64:ff9b::8.8.8.8', + '2002:0a00:0001::', + '2002:0808:0808::', + '2001::1', + '2001:0:53aa:64c:0:fffe:ac10:1', + 'fc00::1', + 'fd12:3456:789a::1', + 'fe80::1', + 'fe80::1%en0', + 'fec0::1', + 'ff02::1', + '[::1]' + ] + const allowed = [ + '1.1.1.1', + '8.8.8.8', + '93.184.216.34', + '100.63.255.255', + '100.128.0.1', + '172.15.255.255', + '172.32.0.1', + '192.0.1.1', + '192.167.255.255', + '198.17.255.255', + '198.20.0.1', + '223.255.255.255', + '2606:4700:4700::1111', + '2a00:1450:4001:80e::200e', + '::ffff:8.8.8.8', + '::8.8.8.8', + '[2606:4700:4700::1001]' + ] + + it.each(blocked)('blocks %s', (address) => { + expect(isBlockedAddress(address)).toBe(true) + }) + + it.each(allowed)('allows %s', (address) => { + expect(isBlockedAddress(address)).toBe(false) + }) + + it('treats unparseable input as blocked', () => { + expect(isBlockedAddress('')).toBe(true) + expect(isBlockedAddress('not-an-ip')).toBe(true) + expect(isBlockedAddress('999.1.1.1')).toBe(true) + }) + + it('honours extra blocked ranges', () => { + expect(isBlockedAddress('8.8.8.8', { blockedRanges: ['8.8.0.0/16'] })).toBe(true) + expect(isBlockedAddress('8.9.0.1', { blockedRanges: ['8.8.0.0/16'] })).toBe(false) + }) + + it('lets the allowlist override a blocked range by CIDR or literal', () => { + expect(isBlockedAddress('10.20.30.40', { allowlist: ['10.20.0.0/16'] })).toBe(false) + expect(isBlockedAddress('10.21.0.1', { allowlist: ['10.20.0.0/16'] })).toBe(true) + expect(isBlockedAddress('192.168.1.5', { allowlist: ['192.168.1.5'] })).toBe(false) + expect(isBlockedAddress('192.168.1.6', { allowlist: ['192.168.1.5'] })).toBe(true) + expect(isBlockedAddress('fd00::5', { allowlist: ['fd00::/64'] })).toBe(false) + }) +}) + +describe('isAllowlistedAddress', () => { + it('ignores hostnames and malformed entries', () => { + expect(isAllowlistedAddress('10.0.0.1', ['caldav.example', 'bad/entry', ''])).toBe(false) + }) + + it('does not match across address families', () => { + expect(isAllowlistedAddress('::ffff:10.0.0.1', ['10.0.0.0/8'])).toBe(false) + }) +}) diff --git a/packages/safe-fetch/src/__tests__/resolve.test.ts b/packages/safe-fetch/src/__tests__/resolve.test.ts new file mode 100644 index 0000000000..ced0814a84 --- /dev/null +++ b/packages/safe-fetch/src/__tests__/resolve.test.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { resolveAndCheck } from '../resolve' +import { type Lookup, SafeFetchError } from '../safe-fetch-types' + +async function codeOf (promise: Promise): Promise { + try { + await promise + } catch (err) { + if (err instanceof SafeFetchError) return err.code + throw err + } + return undefined +} + +const table: Record> = { + 'public.example': [ + { address: '93.184.216.34', family: 4 }, + { address: '2606:2800:220:1:248:1893:25c8:1946', family: 6 } + ], + 'mixed.example': [ + { address: '93.184.216.34', family: 4 }, + { address: '10.0.0.5', family: 4 } + ], + 'private.example': [{ address: '192.168.0.10', family: 4 }], + 'metadata.example': [{ address: '169.254.169.254', family: 4 }], + 'empty.example': [] +} + +const lookup: Lookup = async (hostname) => { + const found = table[hostname] + if (found === undefined) throw new Error('ENOTFOUND') + return found +} + +describe('resolveAndCheck', () => { + it('returns every address of a public hostname', async () => { + const result = await resolveAndCheck('Public.Example.', { lookup }) + expect(result.map((a) => a.address)).toEqual(['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946']) + }) + + it('rejects a hostname when any address is blocked', async () => { + expect(await codeOf(resolveAndCheck('mixed.example', { lookup }))).toBe('BLOCKED_ADDRESS') + expect(await codeOf(resolveAndCheck('private.example', { lookup }))).toBe('BLOCKED_ADDRESS') + expect(await codeOf(resolveAndCheck('metadata.example', { lookup }))).toBe('BLOCKED_ADDRESS') + }) + + it('fails on unknown or empty names', async () => { + expect(await codeOf(resolveAndCheck('missing.example', { lookup }))).toBe('FETCH_FAILED') + expect(await codeOf(resolveAndCheck('empty.example', { lookup }))).toBe('FETCH_FAILED') + }) + + it('skips the address check for an allowlisted hostname', async () => { + const result = await resolveAndCheck('private.example', { lookup, allowlist: ['private.example'] }) + expect(result[0].address).toBe('192.168.0.10') + const wildcard = await resolveAndCheck('private.example', { lookup, allowlist: ['*.example'] }) + expect(wildcard[0].address).toBe('192.168.0.10') + }) + + it('admits private addresses through a CIDR allowlist entry', async () => { + const result = await resolveAndCheck('private.example', { lookup, allowlist: ['192.168.0.0/24'] }) + expect(result[0].address).toBe('192.168.0.10') + expect(await codeOf(resolveAndCheck('private.example', { lookup, allowlist: ['192.168.1.0/24'] }))).toBe( + 'BLOCKED_ADDRESS' + ) + }) + + it('checks IP literals without consulting DNS', async () => { + const neverCalled: Lookup = async () => { + throw new Error('lookup must not be called for literals') + } + expect(await resolveAndCheck('8.8.8.8', { lookup: neverCalled })).toEqual([{ address: '8.8.8.8', family: 4 }]) + expect(await codeOf(resolveAndCheck('10.0.0.1', { lookup: neverCalled }))).toBe('BLOCKED_ADDRESS') + }) +}) diff --git a/packages/safe-fetch/src/__tests__/safe-url.test.ts b/packages/safe-fetch/src/__tests__/safe-url.test.ts new file mode 100644 index 0000000000..2a2010d83d --- /dev/null +++ b/packages/safe-fetch/src/__tests__/safe-url.test.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { SafeFetchError } from '../safe-fetch-types' +import { isAllowlistedHost, normalizeHostname, validateUrl } from '../safe-url' + +function codeOf (fn: () => unknown): string | undefined { + try { + fn() + } catch (err) { + if (err instanceof SafeFetchError) return err.code + throw err + } + return undefined +} + +describe('normalizeHostname', () => { + it('lower-cases, strips brackets and trailing dots', () => { + expect(normalizeHostname('CalDAV.Example.COM.')).toBe('caldav.example.com') + expect(normalizeHostname('[::1]')).toBe('::1') + }) +}) + +describe('isAllowlistedHost', () => { + it('matches exact names and wildcards, ignores CIDRs', () => { + const list = ['caldav.example', '*.internal.test', '10.0.0.0/8'] + expect(isAllowlistedHost('CALDAV.EXAMPLE', list)).toBe(true) + expect(isAllowlistedHost('a.internal.test', list)).toBe(true) + expect(isAllowlistedHost('internal.test', list)).toBe(false) + expect(isAllowlistedHost('10.0.0.1', list)).toBe(false) + expect(isAllowlistedHost('other.example', list)).toBe(false) + expect(isAllowlistedHost('other.example', undefined)).toBe(false) + }) +}) + +describe('validateUrl', () => { + it('accepts https to a public host', () => { + expect(validateUrl('https://caldav.example/dav/').href).toBe('https://caldav.example/dav/') + }) + + it('rejects http unless allowed', () => { + expect(codeOf(() => validateUrl('http://caldav.example/'))).toBe('INVALID_PROTOCOL') + expect(validateUrl('http://caldav.example/', { allowHttp: true }).protocol).toBe('http:') + }) + + it('rejects other protocols, malformed input and embedded credentials', () => { + expect(codeOf(() => validateUrl('ftp://caldav.example/'))).toBe('INVALID_PROTOCOL') + expect(codeOf(() => validateUrl('file:///etc/passwd'))).toBe('INVALID_PROTOCOL') + expect(codeOf(() => validateUrl('not a url'))).toBe('INVALID_URL') + expect(codeOf(() => validateUrl('https://user:pw@caldav.example/'))).toBe('INVALID_URL') + }) + + it('rejects localhost names and blocked IP literals', () => { + expect(codeOf(() => validateUrl('https://localhost/'))).toBe('BLOCKED_HOST') + expect(codeOf(() => validateUrl('https://LOCALHOST./'))).toBe('BLOCKED_HOST') + expect(codeOf(() => validateUrl('https://foo.localhost/'))).toBe('BLOCKED_HOST') + expect(codeOf(() => validateUrl('https://127.0.0.1/'))).toBe('BLOCKED_ADDRESS') + expect(codeOf(() => validateUrl('https://[::1]/'))).toBe('BLOCKED_ADDRESS') + expect(codeOf(() => validateUrl('https://169.254.169.254/latest/meta-data/'))).toBe('BLOCKED_ADDRESS') + expect(codeOf(() => validateUrl('https://[::ffff:10.0.0.1]/'))).toBe('BLOCKED_ADDRESS') + }) + + it('lets the allowlist admit a private literal or a local name', () => { + expect(validateUrl('https://192.168.1.10/', { allowlist: ['192.168.1.0/24'] }).hostname).toBe('192.168.1.10') + expect(validateUrl('https://radicale.localhost/', { allowlist: ['radicale.localhost'] }).hostname).toBe( + 'radicale.localhost' + ) + }) + + it('does not resolve hostnames at this stage', () => { + expect(validateUrl('https://minio/').hostname).toBe('minio') + }) +}) diff --git a/packages/safe-fetch/src/fetch.ts b/packages/safe-fetch/src/fetch.ts new file mode 100644 index 0000000000..9042996af6 --- /dev/null +++ b/packages/safe-fetch/src/fetch.ts @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: EPL-2.0 + +import type dns from 'dns' +import type net from 'net' +import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici' +import { resolveAndCheck } from './resolve' +import { + DEFAULT_MAX_BODY_BYTES, + DEFAULT_MAX_REDIRECTS, + DEFAULT_TIMEOUT_MS, + type ResolvedAddress, + SafeFetchError, + type SafeFetchOptions +} from './safe-fetch-types' +import { normalizeHostname, validateUrl } from './safe-url' + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) + +type LookupCallback = ( + err: NodeJS.ErrnoException | null, + address: string | dns.LookupAddress[], + family?: number +) => void + +/** + * Returns the only addresses a connection may use for a hostname. Filled by safeFetch right before each + * request, so the socket cannot be opened to an address that was never checked (DNS rebinding). + */ +class PinnedAddresses { + private readonly pinned = new Map() + + set (hostname: string, addresses: ResolvedAddress[]): void { + this.pinned.set(normalizeHostname(hostname), addresses) + } + + readonly lookup: net.LookupFunction = (hostname: string, options: dns.LookupOptions, callback: LookupCallback) => { + const addresses = this.pinned.get(normalizeHostname(hostname)) ?? [] + const family = typeof options.family === 'number' ? options.family : undefined + const matching = family === 4 || family === 6 ? addresses.filter((a) => a.family === family) : addresses + if (matching.length === 0) { + const err: NodeJS.ErrnoException = new Error(`safe-fetch: no checked address for ${hostname}`) + err.code = 'ENOTFOUND' + callback(err, '', 4) + return + } + if (options.all === true) { + callback( + null, + matching.map((a) => ({ address: a.address, family: a.family })) + ) + return + } + callback(null, matching[0].address, matching[0].family) + } +} + +function headersToRecord (init: HeadersInit | undefined): Record { + const result: Record = {} + if (init === undefined) return result + const entries = + init instanceof Headers ? Array.from(init.entries()) : Array.isArray(init) ? init : Object.entries(init) + for (const [key, value] of entries) { + result[key.toLowerCase()] = value + } + return result +} + +function limitBody (response: UndiciResponse, maxBytes: number, url: string): UndiciResponse { + const declared = response.headers.get('content-length') + if (declared !== null && Number(declared) > maxBytes) { + void response.body?.cancel() + throw new SafeFetchError('BODY_TOO_LARGE', `Response body exceeds ${maxBytes} bytes`, url) + } + const source = response.body as unknown as ReadableStream | null + if (source === null) return response + + let total = 0 + const limited = source.pipeThrough( + new TransformStream({ + transform (chunk, controller) { + total += chunk.byteLength + if (total > maxBytes) { + controller.error(new SafeFetchError('BODY_TOO_LARGE', `Response body exceeds ${maxBytes} bytes`, url)) + return + } + controller.enqueue(chunk) + } + }) + ) + // Every reader goes through a throwaway Response over the limited stream, so text(), json() and + // arrayBuffer() keep their native decoding while status, headers and url stay on the original object. + const reader = new Response(limited as unknown as ReadableStream) + Object.defineProperties(response, { + body: { get: () => limited, configurable: true }, + text: { value: async () => await reader.text(), configurable: true }, + json: { value: async () => await reader.json(), configurable: true }, + arrayBuffer: { value: async () => await reader.arrayBuffer(), configurable: true }, + bytes: { value: async () => new Uint8Array(await reader.arrayBuffer()), configurable: true }, + blob: { value: async () => await reader.blob(), configurable: true } + }) + return response +} + +/** + * Creates a fetch function that only reaches hosts and addresses that pass the SSRF checks. + * It honours the caller's redirect mode: 'follow' validates each hop, 'manual' returns the 3xx response + * unchanged, 'error' throws on any redirect. + */ +export function createSafeFetch (opts: SafeFetchOptions = {}): typeof fetch { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS + const maxRedirects = opts.maxRedirects ?? DEFAULT_MAX_REDIRECTS + const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES + const pinned = new PinnedAddresses() + const agent = new Agent({ connect: { lookup: pinned.lookup, timeout: timeoutMs } }) + + const safeFetch = async (input: string | URL | Request, init: RequestInit = {}): Promise => { + const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + let current = validateUrl(rawUrl, opts) + const mode = init.redirect ?? 'follow' + const timeoutSignal = AbortSignal.timeout(timeoutMs) + const signal = init.signal != null ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal + let method = (init.method ?? 'GET').toUpperCase() + let body = init.body + const headers = headersToRecord(init.headers) + + for (let hop = 0; ; hop++) { + const addresses = await resolveAndCheck(current.hostname, opts) + pinned.set(current.hostname, addresses) + + let response: UndiciResponse + try { + response = await undiciFetch(current.href, { + method, + headers, + body: body as never, + signal, + redirect: 'manual', + dispatcher: agent + }) + } catch (err) { + if (timeoutSignal.aborted) { + throw new SafeFetchError('TIMEOUT', `Request timed out after ${timeoutMs} ms`, current.href) + } + if (init.signal?.aborted === true) throw err + if (err instanceof SafeFetchError) throw err + const cause = err instanceof Error ? err.message : String(err) + throw new SafeFetchError('FETCH_FAILED', `Request failed: ${cause}`, current.href) + } + + if (!REDIRECT_STATUSES.has(response.status) || mode === 'manual') { + return limitBody(response, maxBodyBytes, current.href) as unknown as Response + } + const location = response.headers.get('location') + void response.body?.cancel() + if (mode === 'error') { + throw new SafeFetchError('REDIRECT_NOT_ALLOWED', `Redirect to ${location ?? '?'} not allowed`, current.href) + } + if (location === null) { + return response as unknown as Response + } + if (hop >= maxRedirects) { + throw new SafeFetchError('TOO_MANY_REDIRECTS', `More than ${maxRedirects} redirects`, current.href) + } + + let next: URL + try { + next = new URL(location, current) + } catch { + throw new SafeFetchError('INVALID_URL', `Invalid redirect location: ${location}`, current.href) + } + next.hash = '' + if (next.origin !== current.origin) { + // Never forward credentials to another origin + delete headers.authorization + delete headers.cookie + } + if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) { + method = 'GET' + body = undefined + delete headers['content-type'] + delete headers['content-length'] + } + current = validateUrl(next.href, opts) + } + } + + return safeFetch as typeof fetch +} diff --git a/packages/safe-fetch/src/index.ts b/packages/safe-fetch/src/index.ts new file mode 100644 index 0000000000..b378abbe57 --- /dev/null +++ b/packages/safe-fetch/src/index.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: EPL-2.0 + +export { createSafeFetch } from './fetch' +export { BLOCKED_IPV4_RANGES, BLOCKED_IPV6_RANGES, isAllowlistedAddress, isBlockedAddress } from './ranges' +export { defaultLookup, resolveAndCheck } from './resolve' +export { + DEFAULT_MAX_BODY_BYTES, + DEFAULT_MAX_REDIRECTS, + DEFAULT_TIMEOUT_MS, + type Lookup, + type ResolvedAddress, + SafeFetchError, + type SafeFetchErrorCode, + type SafeFetchOptions +} from './safe-fetch-types' +export { isAllowlistedHost, normalizeHostname, validateUrl } from './safe-url' diff --git a/packages/safe-fetch/src/ranges.ts b/packages/safe-fetch/src/ranges.ts new file mode 100644 index 0000000000..bc5ed2832a --- /dev/null +++ b/packages/safe-fetch/src/ranges.ts @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: EPL-2.0 + +import ipaddr from 'ipaddr.js' +import { type SafeFetchOptions } from './safe-fetch-types' + +type Address = ipaddr.IPv4 | ipaddr.IPv6 +type Range = [Address, number] + +/** IPv4 ranges that are never reachable from user-supplied URLs unless allowlisted. */ +export const BLOCKED_IPV4_RANGES: readonly string[] = [ + '0.0.0.0/8', // "this" network + '10.0.0.0/8', // private + '100.64.0.0/10', // carrier-grade NAT + '127.0.0.0/8', // loopback + '169.254.0.0/16', // link-local, cloud metadata endpoints + '172.16.0.0/12', // private + '192.0.0.0/24', // IETF protocol assignments + '192.0.2.0/24', // documentation + '192.168.0.0/16', // private + '198.18.0.0/15', // benchmarking + '198.51.100.0/24', // documentation + '203.0.113.0/24', // documentation + '224.0.0.0/4', // multicast + '240.0.0.0/4' // reserved, includes broadcast +] + +/** IPv6 ranges that are never reachable from user-supplied URLs unless allowlisted. */ +export const BLOCKED_IPV6_RANGES: readonly string[] = [ + '::/128', // unspecified + '::1/128', // loopback + '64:ff9b::/96', // NAT64 well-known prefix, blocked as a whole + '2001::/32', // Teredo, blocked as a whole + '2002::/16', // 6to4, blocked as a whole + 'fc00::/7', // unique local + 'fe80::/10', // link-local + 'fec0::/10', // site-local (deprecated) + 'ff00::/8' // multicast +] + +const builtinRanges: Range[] = [...BLOCKED_IPV4_RANGES, ...BLOCKED_IPV6_RANGES].map((cidr) => ipaddr.parseCIDR(cidr)) + +const extraRangeCache = new WeakMap() + +function parseRanges (cidrs: string[] | undefined): Range[] { + if (cidrs === undefined || cidrs.length === 0) return [] + const cached = extraRangeCache.get(cidrs) + if (cached !== undefined) return cached + const parsed: Range[] = [] + for (const cidr of cidrs) { + parsed.push(ipaddr.parseCIDR(cidr.trim())) + } + extraRangeCache.set(cidrs, parsed) + return parsed +} + +function matchesAny (addr: Address, ranges: Range[]): boolean { + for (const range of ranges) { + if (range[0].kind() === addr.kind() && addr.match(range)) return true + } + return false +} + +function ipv4FromParts (hi: number, lo: number): ipaddr.IPv4 { + return new ipaddr.IPv4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff]) +} + +/** + * Returns the IPv4 address embedded in the deprecated IPv4-compatible form ::a.b.c.d, if any. + * NAT64, 6to4 and Teredo prefixes are blocked wholesale instead, see BLOCKED_IPV6_RANGES. + */ +function embeddedIpv4 (addr: ipaddr.IPv6): ipaddr.IPv4 | undefined { + const parts = addr.parts + const leadingZero = + parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0 + if (leadingZero && (parts[6] !== 0 || parts[7] > 1)) { + return ipv4FromParts(parts[6], parts[7]) + } + return undefined +} + +function isBlocked (addr: Address, extra: Range[]): boolean { + if (addr.kind() === 'ipv6') { + const v6 = addr as ipaddr.IPv6 + if (v6.isIPv4MappedAddress()) return isBlocked(v6.toIPv4Address(), extra) + const embedded = embeddedIpv4(v6) + if (embedded !== undefined && isBlocked(embedded, extra)) return true + } + return matchesAny(addr, builtinRanges) || matchesAny(addr, extra) +} + +function parseAddress (address: string): Address | undefined { + let value = address.trim() + if (value.startsWith('[') && value.endsWith(']')) value = value.slice(1, -1) + // Strip an IPv6 zone index such as %en0 + const zone = value.indexOf('%') + if (zone !== -1) value = value.slice(0, zone) + try { + return ipaddr.parse(value) + } catch { + return undefined + } +} + +/** True when the allowlist names this address, either as a literal or through a CIDR range. */ +export function isAllowlistedAddress (address: string, allowlist: string[] | undefined): boolean { + if (allowlist === undefined || allowlist.length === 0) return false + const addr = parseAddress(address) + if (addr === undefined) return false + for (const entry of allowlist) { + const item = entry.trim() + if (item.includes('/')) { + try { + const range = ipaddr.parseCIDR(item) + if (range[0].kind() === addr.kind() && addr.match(range)) return true + } catch { + // Not a CIDR, fall through to hostname handling elsewhere + } + continue + } + const literal = parseAddress(item) + if (literal !== undefined && literal.kind() === addr.kind() && literal.toString() === addr.toString()) return true + } + return false +} + +/** + * True when the address must not be contacted. Unparseable input is treated as blocked. + * Allowlisted addresses are never blocked. + */ +export function isBlockedAddress (address: string, opts: SafeFetchOptions = {}): boolean { + const addr = parseAddress(address) + if (addr === undefined) return true + if (isAllowlistedAddress(address, opts.allowlist)) return false + return isBlocked(addr, parseRanges(opts.blockedRanges)) +} diff --git a/packages/safe-fetch/src/resolve.ts b/packages/safe-fetch/src/resolve.ts new file mode 100644 index 0000000000..c4016cd0b6 --- /dev/null +++ b/packages/safe-fetch/src/resolve.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: EPL-2.0 + +import dns from 'dns' +import net from 'net' +import { isBlockedAddress } from './ranges' +import { type Lookup, type ResolvedAddress, SafeFetchError, type SafeFetchOptions } from './safe-fetch-types' +import { isAllowlistedHost, normalizeHostname } from './safe-url' + +/** Default resolver: every A and AAAA record in the order the resolver returns them. */ +export const defaultLookup: Lookup = async (hostname) => { + const records = await dns.promises.lookup(hostname, { all: true, verbatim: true }) + return records.map((r) => ({ address: r.address, family: r.family === 6 ? 6 : 4 })) +} + +/** + * Resolves a hostname and checks every address against the blocked ranges. + * Throws when any address is blocked, so a name that mixes public and private records is rejected. + * The returned addresses are the only ones a connection may use. + */ +export async function resolveAndCheck (hostname: string, opts: SafeFetchOptions = {}): Promise { + const host = normalizeHostname(hostname) + const literal = net.isIP(host) + if (literal !== 0) { + if (isBlockedAddress(host, opts)) { + throw new SafeFetchError('BLOCKED_ADDRESS', `Address not allowed: ${host}`) + } + return [{ address: host, family: literal === 6 ? 6 : 4 }] + } + + const lookup = opts.lookup ?? defaultLookup + let addresses: ResolvedAddress[] + try { + addresses = await lookup(host) + } catch (err) { + throw new SafeFetchError( + 'FETCH_FAILED', + `Cannot resolve ${host}: ${err instanceof Error ? err.message : String(err)}` + ) + } + if (addresses.length === 0) { + throw new SafeFetchError('FETCH_FAILED', `Cannot resolve ${host}: no addresses`) + } + if (isAllowlistedHost(host, opts.allowlist)) { + return addresses + } + for (const a of addresses) { + if (isBlockedAddress(a.address, opts)) { + throw new SafeFetchError('BLOCKED_ADDRESS', `${host} resolves to a blocked address ${a.address}`) + } + } + return addresses +} diff --git a/packages/safe-fetch/src/safe-fetch-types.ts b/packages/safe-fetch/src/safe-fetch-types.ts new file mode 100644 index 0000000000..45f1c54b78 --- /dev/null +++ b/packages/safe-fetch/src/safe-fetch-types.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: EPL-2.0 + +/** One address a hostname resolved to. */ +export interface ResolvedAddress { + address: string + family: 4 | 6 +} + +/** Resolves a hostname to all of its addresses. Injectable so tests never touch real DNS. */ +export type Lookup = (hostname: string) => Promise + +export interface SafeFetchOptions { + /** Allow plain http: URLs. Default false. */ + allowHttp?: boolean + /** + * Hostnames, IP literals or CIDR ranges that may be contacted even when they fall in a blocked range. + * Hostnames match exactly or as a `*.suffix` wildcard. Intended for operator configuration only. + */ + allowlist?: string[] + /** Extra CIDR ranges to block in addition to the built-in list. */ + blockedRanges?: string[] + /** Timeout for the whole request including redirects, in milliseconds. Default 30000. */ + timeoutMs?: number + /** Maximum number of redirects followed when the caller uses redirect: 'follow'. Default 5. */ + maxRedirects?: number + /** Maximum response body size in bytes. Default 10 MiB. */ + maxBodyBytes?: number + /** DNS resolver override. Default uses dns.promises.lookup with all addresses. */ + lookup?: Lookup +} + +export type SafeFetchErrorCode = + | 'INVALID_URL' + | 'INVALID_PROTOCOL' + | 'BLOCKED_HOST' + | 'BLOCKED_ADDRESS' + | 'REDIRECT_NOT_ALLOWED' + | 'TOO_MANY_REDIRECTS' + | 'TIMEOUT' + | 'BODY_TOO_LARGE' + | 'FETCH_FAILED' + +export class SafeFetchError extends Error { + constructor ( + readonly code: SafeFetchErrorCode, + message: string, + readonly url?: string + ) { + super(message) + this.name = 'SafeFetchError' + } +} + +export const DEFAULT_TIMEOUT_MS = 30_000 +export const DEFAULT_MAX_REDIRECTS = 5 +export const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024 diff --git a/packages/safe-fetch/src/safe-url.ts b/packages/safe-fetch/src/safe-url.ts new file mode 100644 index 0000000000..fd9550c079 --- /dev/null +++ b/packages/safe-fetch/src/safe-url.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: EPL-2.0 + +import net from 'net' +import { isBlockedAddress } from './ranges' +import { SafeFetchError, type SafeFetchOptions } from './safe-fetch-types' + +/** Lower-cases a hostname, strips IPv6 brackets and a trailing dot. */ +export function normalizeHostname (hostname: string): string { + let host = hostname.trim().toLowerCase() + if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1) + if (host.endsWith('.')) host = host.slice(0, -1) + return host +} + +/** True when the allowlist names this hostname exactly or through a `*.suffix` wildcard. */ +export function isAllowlistedHost (hostname: string, allowlist: string[] | undefined): boolean { + if (allowlist === undefined || allowlist.length === 0) return false + const host = normalizeHostname(hostname) + for (const entry of allowlist) { + const item = normalizeHostname(entry) + if (item === '' || item.includes('/')) continue + if (item.startsWith('*.')) { + const suffix = item.slice(1) + if (host.endsWith(suffix) && host.length > suffix.length) return true + continue + } + if (item === host) return true + } + return false +} + +/** + * Parses and checks a URL before any network activity. + * Rejects unsupported protocols, embedded credentials, localhost names and blocked IP literals. + */ +export function validateUrl (input: string | URL, opts: SafeFetchOptions = {}): URL { + let url: URL + try { + url = new URL(typeof input === 'string' ? input : input.href) + } catch { + throw new SafeFetchError('INVALID_URL', `Invalid URL: ${String(input)}`) + } + const allowedProtocols = opts.allowHttp === true ? ['https:', 'http:'] : ['https:'] + if (!allowedProtocols.includes(url.protocol)) { + throw new SafeFetchError('INVALID_PROTOCOL', `Protocol not allowed: ${url.protocol}`, url.href) + } + if (url.username !== '' || url.password !== '') { + throw new SafeFetchError('INVALID_URL', 'Credentials in the URL are not allowed', url.href) + } + const host = normalizeHostname(url.hostname) + if (host === '') { + throw new SafeFetchError('INVALID_URL', 'URL has no host', url.href) + } + if (isAllowlistedHost(host, opts.allowlist)) { + return url + } + if (host === 'localhost' || host.endsWith('.localhost')) { + throw new SafeFetchError('BLOCKED_HOST', `Host not allowed: ${host}`, url.href) + } + if (net.isIP(host) !== 0 && isBlockedAddress(host, opts)) { + throw new SafeFetchError('BLOCKED_ADDRESS', `Address not allowed: ${host}`, url.href) + } + return url +} diff --git a/packages/safe-fetch/tsconfig.json b/packages/safe-fetch/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/packages/safe-fetch/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/plugins/calendar/src/index.ts b/plugins/calendar/src/index.ts index f85c49abd3..897dddc8bf 100644 --- a/plugins/calendar/src/index.ts +++ b/plugins/calendar/src/index.ts @@ -57,6 +57,21 @@ export interface ExternalCalendar extends Calendar { externalUser: string } +/** + * @public + * + * Marks an ExternalCalendar as owned by a CalDAV account. Google calendars never carry this mixin, + * so provider-specific code can tell the two apart by its presence. + */ +export interface CalDavCalendar extends ExternalCalendar { + // Key of the account secret in the account service, see caldavCalendarIntegrationKind + accountKey: string + // Collection href on the CalDAV server, absolute path without origin + href: string + // Last seen getctag, informational only + ctag?: string +} + export interface PrimaryCalendar extends Preference { attachedTo: Ref } @@ -184,6 +199,14 @@ export const calendarIntegrationKind = 'google-calendar' as IntegrationKind */ export const caldavIntegrationKind = 'caldav' as IntegrationKind +/** + * @public + * + * Integration kind for syncing calendars from an external CalDAV server into the platform. + * Distinct from caldavIntegrationKind, which is the platform acting as a CalDAV server. + */ +export const caldavCalendarIntegrationKind = 'caldav-calendar' as IntegrationKind + /** * @public */ @@ -203,7 +226,8 @@ const calendarPlugin = plugin(calendarId, { PrimaryCalendar: '' as Ref> }, mixin: { - CalendarEventPresenter: '' as Ref> + CalendarEventPresenter: '' as Ref>, + CalDavCalendar: '' as Ref> }, icon: { Calendar: '' as Asset, diff --git a/rush.json b/rush.json index b46ef7ebb6..d13fd63d76 100644 --- a/rush.json +++ b/rush.json @@ -2591,6 +2591,11 @@ "projectFolder": "packages/kvs-client", "shouldPublish": false }, + { + "packageName": "@hcengineering/safe-fetch", + "projectFolder": "packages/safe-fetch", + "shouldPublish": false + }, { "packageName": "@hcengineering/communication", "projectFolder": "plugins/communication", diff --git a/services/calendar/pod-calendar/src/config.ts b/services/calendar/pod-calendar/src/config.ts index 2f46eb281c..fe1c85f1c2 100644 --- a/services/calendar/pod-calendar/src/config.ts +++ b/services/calendar/pod-calendar/src/config.ts @@ -20,13 +20,25 @@ interface Config { ServiceID: string Secret: string KvsUrl: string - Credentials: string - WATCH_URL: string InitLimit: number WorkspaceInactivityInterval: number // Interval in days to stop workspace synchronization if not visited + + // Google Calendar provider. The Google module starts only when both values are present and non-blank. + Credentials?: string + WATCH_URL?: string + GoogleEnabled: boolean } -const envMap: { [key in keyof Config]: string } = { +type RequiredKey = + | 'Port' + | 'AccountsURL' + | 'ServiceID' + | 'Secret' + | 'KvsUrl' + | 'InitLimit' + | 'WorkspaceInactivityInterval' + +const envMap: { [key in keyof Config]-?: string } = { Port: 'PORT', AccountsURL: 'ACCOUNTS_URL', @@ -36,33 +48,44 @@ const envMap: { [key in keyof Config]: string } = { WATCH_URL: 'WATCH_URL', InitLimit: 'INIT_LIMIT', KvsUrl: 'KVS_URL', - WorkspaceInactivityInterval: 'WORKSPACE_INACTIVITY_INTERVAL' + WorkspaceInactivityInterval: 'WORKSPACE_INACTIVITY_INTERVAL', + GoogleEnabled: 'GOOGLE_ENABLED' // derived, not read from the environment } const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) +// Treat blank values as unset so a deployment can disable Google with Credentials="". +const optionalString = (str: string | undefined): string | undefined => + str !== undefined && str.trim() !== '' ? str : undefined + const config: Config = (() => { - const params: Partial = { + const required: { [key in RequiredKey]: Config[key] | undefined } = { Port: parseNumber(process.env[envMap.Port]) ?? 8095, AccountsURL: process.env[envMap.AccountsURL], ServiceID: process.env[envMap.ServiceID] ?? 'calendar-service', Secret: process.env[envMap.Secret], - Credentials: process.env[envMap.Credentials], InitLimit: parseNumber(process.env[envMap.InitLimit]) ?? 50, - WATCH_URL: process.env[envMap.WATCH_URL], KvsUrl: process.env[envMap.KvsUrl], WorkspaceInactivityInterval: parseNumber(process.env[envMap.WorkspaceInactivityInterval] ?? '3') // In days } - const missingEnv = (Object.keys(params) as Array) - .filter((key) => params[key] === undefined) + const missingEnv = (Object.keys(required) as RequiredKey[]) + .filter((key) => required[key] === undefined) .map((key) => envMap[key]) if (missingEnv.length > 0) { throw Error(`Missing env variables: ${missingEnv.join(', ')}`) } - return params as Config + const credentials = optionalString(process.env[envMap.Credentials]) + const watchUrl = optionalString(process.env[envMap.WATCH_URL]) + + return { + ...(required as { [key in RequiredKey]: Config[key] }), + Credentials: credentials, + WATCH_URL: watchUrl, + GoogleEnabled: credentials !== undefined && watchUrl !== undefined + } })() export default config diff --git a/services/calendar/pod-calendar/src/main.ts b/services/calendar/pod-calendar/src/main.ts index 637d11933c..9cf1306e29 100644 --- a/services/calendar/pod-calendar/src/main.ts +++ b/services/calendar/pod-calendar/src/main.ts @@ -62,14 +62,31 @@ export const main = async (): Promise => { config.ServiceID ) - const pushHandler = new PushHandler(ctx, accountClient) - const watchController = WatchController.get(ctx, accountClient) + // Google Calendar provider. Skipped entirely when the OAuth client or the webhook URL is not configured, + // so the service can run with other providers only. + let pushHandler: PushHandler | undefined + let watchController: WatchController | undefined + if (config.GoogleEnabled) { + pushHandler = new PushHandler(ctx, accountClient) + watchController = WatchController.get(ctx, accountClient) + + const calendarController = CalendarController.getCalendarController(ctx, accountClient) + await calendarController.startAll() + ctx.info('Calendar controller started') + watchController.startCheck() + } else { + ctx.warn('Google Calendar module disabled: Credentials or WATCH_URL is not set') + } - const calendarController = CalendarController.getCalendarController(ctx, accountClient) - await calendarController.startAll() - ctx.info('Calendar controller started') - watchController.startCheck() - const endpoints: Endpoint[] = [ + const googleDisabledEndpoints: Endpoint[] = ['/signin', '/signout'].map((endpoint) => ({ + endpoint, + type: 'get', + handler: async (_req, res) => { + res.status(501).send({ error: 'google-disabled' }) + } + })) + + const googleEndpoints: Endpoint[] = [ { endpoint: '/signin', type: 'get', @@ -150,12 +167,16 @@ export const main = async (): Promise => { res.status(400).send({ err: "'data' is missing" }) return } - await pushHandler.push(data.user as GoogleEmail, data.mode as 'events' | 'calendar', data.calendarId) + await pushHandler?.push(data.user as GoogleEmail, data.mode as 'events' | 'calendar', data.calendarId) } res.send() } - }, + } + ] + + const endpoints: Endpoint[] = [ + ...(config.GoogleEnabled ? googleEndpoints : googleDisabledEndpoints), { endpoint: '/event', type: 'post', @@ -185,7 +206,7 @@ export const main = async (): Promise => { const shutdown = (): void => { server.close(() => { - watchController.stop() + watchController?.stop() process.exit() }) } diff --git a/services/calendar/pod-calendar/src/outcomingClient.ts b/services/calendar/pod-calendar/src/outcomingClient.ts index a88e957cd7..ca5416852b 100644 --- a/services/calendar/pod-calendar/src/outcomingClient.ts +++ b/services/calendar/pod-calendar/src/outcomingClient.ts @@ -478,6 +478,8 @@ async function getTokenByEvent ( _id: event.calendar as Ref }) if (_calendar === undefined) return + // CalDAV calendars are written by the CalDAV module, there is no Google token for them + if (txOp.getHierarchy().hasMixin(_calendar, calendar.mixin.CalDavCalendar)) return const res = await accountClient.getIntegrationSecret({ socialId: _calendar.user, kind: calendarIntegrationKind, diff --git a/services/calendar/pod-calendar/src/sync.ts b/services/calendar/pod-calendar/src/sync.ts index ec42c5d258..34a1c38d43 100644 --- a/services/calendar/pod-calendar/src/sync.ts +++ b/services/calendar/pod-calendar/src/sync.ts @@ -599,9 +599,12 @@ export class IncomingSyncManager { } private async getMyCalendars (): Promise { - this.calendars = await this.client.findAll(calendar.class.ExternalCalendar, { + const calendars = await this.client.findAll(calendar.class.ExternalCalendar, { user: this.user.userId }) + // CalDAV calendars are owned by the CalDAV module and must never be queried through the Google API + const hierarchy = this.client.getHierarchy() + this.calendars = calendars.filter((c) => !hierarchy.hasMixin(c, calendar.mixin.CalDavCalendar)) } async syncCalendars (): Promise { diff --git a/services/calendar/pod-calendar/src/utils.ts b/services/calendar/pod-calendar/src/utils.ts index f9b3f27b70..9cef9c89b7 100644 --- a/services/calendar/pod-calendar/src/utils.ts +++ b/services/calendar/pod-calendar/src/utils.ts @@ -284,8 +284,8 @@ export function getGoogleClient (): { auth: OAuth2Client google: calendar_v3.Calendar } { - if (config.Credentials === undefined) { - throw new Error('Google Credentials not provided') + if (config.Credentials === undefined || config.Credentials.trim() === '') { + throw new Error('Google Credentials not provided, the Google Calendar module is disabled') } const credentials = JSON.parse(config.Credentials) const { client_secret, client_id, redirect_uris } = credentials.web // eslint-disable-line diff --git a/services/calendar/pod-calendar/src/workspaceClient.ts b/services/calendar/pod-calendar/src/workspaceClient.ts index d14155ffb3..7482a60504 100644 --- a/services/calendar/pod-calendar/src/workspaceClient.ts +++ b/services/calendar/pod-calendar/src/workspaceClient.ts @@ -60,7 +60,10 @@ export class WorkspaceClient { } async init (): Promise { - const calendars = await this.client.findAll(calendar.class.ExternalCalendar, {}) + const allCalendars = await this.client.findAll(calendar.class.ExternalCalendar, {}) + // Only Google calendars are pushed through this client; CalDAV calendars carry the CalDavCalendar mixin + const hierarchy = this.client.getHierarchy() + const calendars = allCalendars.filter((c) => !hierarchy.hasMixin(c, calendar.mixin.CalDavCalendar)) this.calendarsById.clear() this.calendarsByExternal.clear() for (const calendar of calendars) { From 5eb12271534450c579784fe6fa01ccc4e91670d0 Mon Sep 17 00:00:00 2001 From: UncleDoomVSSP Date: Wed, 7 Oct 2026 11:30:26 +0100 Subject: [PATCH 2/3] fix(calendar): address review of CalDAV groundwork Follow-up to review comments on #49. pod-calendar: - /event returns immediately when the Google module is disabled, instead of taking the workspace lock and looking up Google secrets per event. - GoogleEnabled is excluded from the envMap type; no placeholder entry. - Revert the duplicate blank-value check in getGoogleClient. safe-fetch: - Pinned addresses are reference-counted and released once a request has connected, so the map no longer grows with the hosts contacted. - A Request input contributes method, headers, body and signal; init wins. - Body limiting skips HEAD and null-body statuses, wraps the limited stream in a native Response so formData() and clone() work, and keeps url and redirected. Redirects without Location are returned readable. - A timeout during the body read surfaces as SafeFetchError('TIMEOUT'). - allowlist and blockedRanges are validated once in createSafeFetch. - Block 64:ff9b:1::/48 and check the IPv4-translated ::ffff:0:a.b.c.d form through its embedded address. - Drop Proxy-Authorization on cross-origin redirects. - Tests for each change; 101 in total. models/calendar: - Distinct embedded labels for the CalDavCalendar mixin fields. Signed-off-by: UncleDoomVSSP Co-Authored-By: Claude Fable 5.1 --- models/calendar/src/index.ts | 7 +- .../safe-fetch/src/__tests__/fetch.test.ts | 125 +++++++++- .../safe-fetch/src/__tests__/ranges.test.ts | 35 ++- packages/safe-fetch/src/fetch.ts | 216 +++++++++++++----- packages/safe-fetch/src/index.ts | 2 +- packages/safe-fetch/src/ranges.ts | 45 +++- services/calendar/pod-calendar/src/config.ts | 6 +- services/calendar/pod-calendar/src/main.ts | 6 + services/calendar/pod-calendar/src/utils.ts | 4 +- 9 files changed, 373 insertions(+), 73 deletions(-) diff --git a/models/calendar/src/index.ts b/models/calendar/src/index.ts index d3db2013ae..7d94dfe7f8 100644 --- a/models/calendar/src/index.ts +++ b/models/calendar/src/index.ts @@ -72,6 +72,7 @@ import workbench from '@hcengineering/model-workbench' import { WidgetType } from '@hcengineering/workbench' import preference, { TPreference } from '@hcengineering/model-preference' import { calendarIntegrationKind } from '@hcengineering/calendar' +import { getEmbeddedLabel } from '@hcengineering/platform' import calendar from './plugin' @@ -102,15 +103,15 @@ export class TExternalCalendar extends TCalendar implements ExternalCalendar { @Mixin(calendar.mixin.CalDavCalendar, calendar.class.ExternalCalendar) export class TCalDavCalendar extends TExternalCalendar implements CalDavCalendar { - @Prop(TypeString(), calendar.string.Account) + @Prop(TypeString(), getEmbeddedLabel('CalDAV account key')) @Hidden() accountKey!: string - @Prop(TypeString(), calendar.string.Calendar) + @Prop(TypeString(), getEmbeddedLabel('CalDAV collection href')) @Hidden() href!: string - @Prop(TypeString(), calendar.string.Calendar) + @Prop(TypeString(), getEmbeddedLabel('CalDAV collection ctag')) @Hidden() ctag?: string } diff --git a/packages/safe-fetch/src/__tests__/fetch.test.ts b/packages/safe-fetch/src/__tests__/fetch.test.ts index c7baab5fac..f6a7122849 100644 --- a/packages/safe-fetch/src/__tests__/fetch.test.ts +++ b/packages/safe-fetch/src/__tests__/fetch.test.ts @@ -2,13 +2,15 @@ import http from 'http' import { type AddressInfo } from 'net' -import { createSafeFetch } from '../fetch' +import { createSafeFetch, PinnedAddresses } from '../fetch' import { type Lookup, SafeFetchError } from '../safe-fetch-types' interface Seen { method: string path: string authorization?: string + proxyAuthorization?: string + contentType?: string body: string } @@ -32,6 +34,8 @@ async function startServer ( method: req.method ?? '', path: req.url ?? '', authorization: req.headers.authorization, + proxyAuthorization: req.headers['proxy-authorization'], + contentType: req.headers['content-type'], body } seen.push(entry) @@ -107,13 +111,36 @@ describe('createSafeFetch', () => { res.write(Buffer.alloc(600, 'b')) res.end() return + case '/head-big': + res.setHeader('content-length', '5000') + res.end() + return + case '/echo': + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ method: seen.method, contentType: seen.contentType ?? null, body: seen.body })) + return + case '/stall': + res.writeHead(200, { 'content-type': 'text/plain' }) + res.write('partial') + // never ends; the client timeout must fire while the body is being read + return + case '/redirect-nolocation': + res.writeHead(302) + res.end('nowhere to go') + return + case '/form': + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('a=1&b=two') + return default: res.writeHead(404) res.end() } }) secondary = await startServer((_req, res, seen) => { - res.end(JSON.stringify({ host: 'other', auth: seen.authorization ?? null })) + res.end( + JSON.stringify({ host: 'other', auth: seen.authorization ?? null, proxy: seen.proxyAuthorization ?? null }) + ) }) }) @@ -149,11 +176,61 @@ describe('createSafeFetch', () => { expect(await res.json()).toEqual({ ok: true, auth: 'Bearer t' }) }) - it('drops the authorization header on a cross-origin redirect', async () => { + it('drops authorization and proxy-authorization on a cross-origin redirect', async () => { const fetch = createSafeFetch(base) - const res = await fetch(url('/redirect-other'), { headers: { authorization: 'Bearer t' } }) + const res = await fetch(url('/redirect-other'), { + headers: { authorization: 'Bearer t', 'proxy-authorization': 'Basic x' } + }) expect(res.status).toBe(200) - expect(await res.json()).toEqual({ host: 'other', auth: null }) + expect(res.redirected).toBe(true) + expect(await res.json()).toEqual({ host: 'other', auth: null, proxy: null }) + }) + + it('keeps method, headers and body from a Request object', async () => { + const fetch = createSafeFetch(base) + const request = new Request(url('/echo'), { + method: 'PROPFIND', + headers: { 'content-type': 'application/xml' }, + body: '' + }) + const res = await fetch(request) + expect(await res.json()).toEqual({ method: 'PROPFIND', contentType: 'application/xml', body: '' }) + const overridden = await fetch(new Request(url('/echo'), { method: 'PROPFIND' }), { method: 'REPORT' }) + expect((await overridden.json()).method).toBe('REPORT') + }) + + it('supports clone() and formData() on the limited body', async () => { + const fetch = createSafeFetch(base) + const res = await fetch(url('/form')) + const copy = res.clone() + const form = await res.formData() + expect(form.get('a')).toBe('1') + expect(await copy.text()).toBe('a=1&b=two') + }) + + it('ignores Content-Length on responses that cannot carry a body', async () => { + const fetch = createSafeFetch({ ...base, maxBodyBytes: 100 }) + const res = await fetch(url('/head-big'), { method: 'HEAD' }) + expect(res.status).toBe(200) + expect(res.headers.get('content-length')).toBe('5000') + }) + + it('returns a redirect without Location as an ordinary readable response', async () => { + const fetch = createSafeFetch(base) + const res = await fetch(url('/redirect-nolocation')) + expect(res.status).toBe(302) + expect(await res.text()).toBe('nowhere to go') + }) + + it('reports TIMEOUT when the timer fires during the body read', async () => { + const fetch = createSafeFetch({ ...base, timeoutMs: 300 }) + const res = await fetch(url('/stall')) + expect(res.status).toBe(200) + expect(await codeOf(res.text())).toBe('TIMEOUT') + }) + + it('fails at creation on a malformed blockedRanges entry', () => { + expect(() => createSafeFetch({ ...base, blockedRanges: ['not-a-cidr'] })).toThrow(/blockedRanges/) }) it('validates every redirect hop against the blocked ranges and DNS', async () => { @@ -200,3 +277,41 @@ describe('createSafeFetch', () => { expect((await fine.text()).length).toBe(1200) }) }) + +describe('PinnedAddresses', () => { + it('removes an entry when the last request for the host releases it', () => { + const pinned = new PinnedAddresses() + const release1 = pinned.acquire('Host.Example', [{ address: '93.184.216.34', family: 4 }]) + const release2 = pinned.acquire('host.example.', [{ address: '93.184.216.34', family: 4 }]) + expect(pinned.size).toBe(1) + release1() + release1() + expect(pinned.size).toBe(1) + release2() + expect(pinned.size).toBe(0) + }) + + it('answers lookups only for pinned hosts and respects the requested family', () => { + const pinned = new PinnedAddresses() + const release = pinned.acquire('dual.example', [ + { address: '2606:4700::1111', family: 6 }, + { address: '1.1.1.1', family: 4 } + ]) + const results: unknown[] = [] + pinned.lookup('dual.example', { family: 4 }, (err, address, family) => results.push([err, address, family])) + pinned.lookup('dual.example', { all: true }, (err, address) => results.push([err, address])) + pinned.lookup('unknown.example', {}, (err) => results.push([err?.code])) + expect(results[0]).toEqual([null, '1.1.1.1', 4]) + expect(results[1]).toEqual([ + null, + [ + { address: '2606:4700::1111', family: 6 }, + { address: '1.1.1.1', family: 4 } + ] + ]) + expect(results[2]).toEqual(['ENOTFOUND']) + release() + pinned.lookup('dual.example', {}, (err) => results.push([err?.code])) + expect(results[3]).toEqual(['ENOTFOUND']) + }) +}) diff --git a/packages/safe-fetch/src/__tests__/ranges.test.ts b/packages/safe-fetch/src/__tests__/ranges.test.ts index 18c8e740d2..75f1f01835 100644 --- a/packages/safe-fetch/src/__tests__/ranges.test.ts +++ b/packages/safe-fetch/src/__tests__/ranges.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: EPL-2.0 -import { isAllowlistedAddress, isBlockedAddress } from '../ranges' +import { isAllowlistedAddress, isBlockedAddress, validateOptions } from '../ranges' describe('isBlockedAddress', () => { const blocked = [ @@ -34,6 +34,9 @@ describe('isBlockedAddress', () => { '::10.0.0.1', '64:ff9b::10.0.0.1', '64:ff9b::8.8.8.8', + '64:ff9b:1::a00:1', + '64:ff9b:1:ffff::808:808', + '::ffff:0:a00:1', '2002:0a00:0001::', '2002:0808:0808::', '2001::1', @@ -62,6 +65,7 @@ describe('isBlockedAddress', () => { '2606:4700:4700::1111', '2a00:1450:4001:80e::200e', '::ffff:8.8.8.8', + '::ffff:0:808:808', '::8.8.8.8', '[2606:4700:4700::1001]' ] @@ -103,3 +107,32 @@ describe('isAllowlistedAddress', () => { expect(isAllowlistedAddress('::ffff:10.0.0.1', ['10.0.0.0/8'])).toBe(false) }) }) + +describe('validateOptions', () => { + it('accepts valid entries', () => { + expect(() => { + validateOptions({ allowlist: ['caldav.example', '*.internal.test', '10.0.0.0/8', '::1'] }) + }).not.toThrow() + expect(() => { + validateOptions({ blockedRanges: ['8.8.0.0/16', 'fd00::/8'] }) + }).not.toThrow() + expect(() => { + validateOptions({}) + }).not.toThrow() + }) + + it('rejects malformed CIDRs and empty allowlist entries up front', () => { + expect(() => { + validateOptions({ blockedRanges: ['8.8.0.0'] }) + }).toThrow(/blockedRanges/) + expect(() => { + validateOptions({ blockedRanges: ['10.0.0.0/33'] }) + }).toThrow(/blockedRanges/) + expect(() => { + validateOptions({ allowlist: ['10.0.0.0/8/'] }) + }).toThrow(/allowlist CIDR/) + expect(() => { + validateOptions({ allowlist: [' '] }) + }).toThrow(/empty allowlist/) + }) +}) diff --git a/packages/safe-fetch/src/fetch.ts b/packages/safe-fetch/src/fetch.ts index 9042996af6..725e643068 100644 --- a/packages/safe-fetch/src/fetch.ts +++ b/packages/safe-fetch/src/fetch.ts @@ -3,6 +3,7 @@ import type dns from 'dns' import type net from 'net' import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici' +import { validateOptions } from './ranges' import { resolveAndCheck } from './resolve' import { DEFAULT_MAX_BODY_BYTES, @@ -15,6 +16,8 @@ import { import { normalizeHostname, validateUrl } from './safe-url' const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) +// Statuses that never carry a body, see RFC 9110 section 6.4.1 +const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]) type LookupCallback = ( err: NodeJS.ErrnoException | null, @@ -22,19 +25,47 @@ type LookupCallback = ( family?: number ) => void +interface PinnedEntry { + addresses: ResolvedAddress[] + refs: number +} + /** - * Returns the only addresses a connection may use for a hostname. Filled by safeFetch right before each - * request, so the socket cannot be opened to an address that was never checked (DNS rebinding). + * Holds the only addresses a connection may use for a hostname while a request to it is in flight. + * safeFetch acquires an entry right before each request and releases it when the request finishes, + * so the socket cannot be opened to an address that was never checked (DNS rebinding) and the map + * does not grow with the number of distinct hosts contacted. */ -class PinnedAddresses { - private readonly pinned = new Map() +export class PinnedAddresses { + private readonly pinned = new Map() - set (hostname: string, addresses: ResolvedAddress[]): void { - this.pinned.set(normalizeHostname(hostname), addresses) + get size (): number { + return this.pinned.size + } + + /** Pins the addresses for a hostname and returns the function that releases the pin. */ + acquire (hostname: string, addresses: ResolvedAddress[]): () => void { + const host = normalizeHostname(hostname) + const entry = this.pinned.get(host) + if (entry !== undefined) { + entry.addresses = addresses + entry.refs++ + } else { + this.pinned.set(host, { addresses, refs: 1 }) + } + let released = false + return () => { + if (released) return + released = true + const current = this.pinned.get(host) + if (current === undefined) return + current.refs-- + if (current.refs <= 0) this.pinned.delete(host) + } } readonly lookup: net.LookupFunction = (hostname: string, options: dns.LookupOptions, callback: LookupCallback) => { - const addresses = this.pinned.get(normalizeHostname(hostname)) ?? [] + const addresses = this.pinned.get(normalizeHostname(hostname))?.addresses ?? [] const family = typeof options.family === 'number' ? options.family : undefined const matching = family === 4 || family === 6 ? addresses.filter((a) => a.family === family) : addresses if (matching.length === 0) { @@ -65,48 +96,126 @@ function headersToRecord (init: HeadersInit | undefined): Record return result } -function limitBody (response: UndiciResponse, maxBytes: number, url: string): UndiciResponse { - const declared = response.headers.get('content-length') - if (declared !== null && Number(declared) > maxBytes) { - void response.body?.cancel() - throw new SafeFetchError('BODY_TOO_LARGE', `Response body exceeds ${maxBytes} bytes`, url) +interface RequestParts { + url: string + method: string + headers: Record + body: BodyInit | null | undefined + signal: AbortSignal | null | undefined + redirect: RequestRedirect +} + +/** Merges a Request object with init the way fetch does: init wins, the Request supplies the rest. */ +async function toRequestParts (input: string | URL | Request, init: RequestInit): Promise { + if (!(input instanceof Request)) { + return { + url: typeof input === 'string' ? input : input.href, + method: init.method ?? 'GET', + headers: headersToRecord(init.headers), + body: init.body, + signal: init.signal, + redirect: init.redirect ?? 'follow' + } + } + let body: BodyInit | null | undefined = init.body + if (body === undefined && input.body !== null) { + // The Request body is a stream; buffer it so it can be re-sent after a redirect. + body = await input.arrayBuffer() + } + return { + url: input.url, + method: init.method ?? input.method, + headers: init.headers !== undefined ? headersToRecord(init.headers) : headersToRecord(input.headers), + body, + signal: init.signal ?? input.signal, + redirect: init.redirect ?? input.redirect } - const source = response.body as unknown as ReadableStream | null - if (source === null) return response +} +/** + * Wraps a body stream so that reading past maxBytes fails with BODY_TOO_LARGE and a timeout that fires + * while the body is still arriving surfaces as TIMEOUT rather than the runtime's own abort error. + */ +function limitStream ( + source: ReadableStream, + maxBytes: number, + url: string, + timeoutSignal: AbortSignal +): ReadableStream { + const reader = source.getReader() let total = 0 - const limited = source.pipeThrough( - new TransformStream({ - transform (chunk, controller) { - total += chunk.byteLength - if (total > maxBytes) { - controller.error(new SafeFetchError('BODY_TOO_LARGE', `Response body exceeds ${maxBytes} bytes`, url)) - return + return new ReadableStream({ + async pull (controller) { + let result: ReadableStreamReadResult + try { + result = await reader.read() + } catch (err) { + if (timeoutSignal.aborted) { + throw new SafeFetchError('TIMEOUT', 'Request timed out while reading the response body', url) } - controller.enqueue(chunk) + throw err + } + if (result.done) { + controller.close() + return + } + total += result.value.byteLength + if (total > maxBytes) { + await reader.cancel().catch(() => undefined) + throw new SafeFetchError('BODY_TOO_LARGE', `Response body exceeds ${maxBytes} bytes`, url) } - }) - ) - // Every reader goes through a throwaway Response over the limited stream, so text(), json() and - // arrayBuffer() keep their native decoding while status, headers and url stay on the original object. - const reader = new Response(limited as unknown as ReadableStream) - Object.defineProperties(response, { - body: { get: () => limited, configurable: true }, - text: { value: async () => await reader.text(), configurable: true }, - json: { value: async () => await reader.json(), configurable: true }, - arrayBuffer: { value: async () => await reader.arrayBuffer(), configurable: true }, - bytes: { value: async () => new Uint8Array(await reader.arrayBuffer()), configurable: true }, - blob: { value: async () => await reader.blob(), configurable: true } + controller.enqueue(result.value) + }, + async cancel (reason) { + await reader.cancel(reason).catch(() => undefined) + } }) - return response +} + +/** + * Returns a Response whose body is size-limited. Responses that cannot carry a body (HEAD, 204, 205, 304) + * are returned as they are, whatever Content-Length says. The result is a native Response built over the + * limited stream, so text(), json(), formData() and clone() all behave normally; url and redirected are + * carried over from the original. + */ +function wrapResponse ( + response: UndiciResponse, + method: string, + maxBytes: number, + url: string, + redirected: boolean, + timeoutSignal: AbortSignal +): Response { + const hasBody = method !== 'HEAD' && !NULL_BODY_STATUSES.has(response.status) && response.body !== null + if (!hasBody) { + return response as unknown as Response + } + const declared = response.headers.get('content-length') + if (declared !== null && Number(declared) > maxBytes) { + void response.body?.cancel() + throw new SafeFetchError('BODY_TOO_LARGE', `Response body exceeds ${maxBytes} bytes`, url) + } + const limited = limitStream(response.body as unknown as ReadableStream, maxBytes, url, timeoutSignal) + const wrapped = new Response(limited, { + status: response.status, + statusText: response.statusText, + headers: response.headers as unknown as HeadersInit + }) + Object.defineProperties(wrapped, { + url: { value: response.url, configurable: true }, + redirected: { value: redirected, configurable: true } + }) + return wrapped } /** * Creates a fetch function that only reaches hosts and addresses that pass the SSRF checks. * It honours the caller's redirect mode: 'follow' validates each hop, 'manual' returns the 3xx response - * unchanged, 'error' throws on any redirect. + * unchanged, 'error' throws on any redirect. Invalid allowlist or blockedRanges entries fail here, + * not on the first request. */ export function createSafeFetch (opts: SafeFetchOptions = {}): typeof fetch { + validateOptions(opts) const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS const maxRedirects = opts.maxRedirects ?? DEFAULT_MAX_REDIRECTS const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES @@ -114,18 +223,17 @@ export function createSafeFetch (opts: SafeFetchOptions = {}): typeof fetch { const agent = new Agent({ connect: { lookup: pinned.lookup, timeout: timeoutMs } }) const safeFetch = async (input: string | URL | Request, init: RequestInit = {}): Promise => { - const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - let current = validateUrl(rawUrl, opts) - const mode = init.redirect ?? 'follow' + const parts = await toRequestParts(input, init) + let current = validateUrl(parts.url, opts) const timeoutSignal = AbortSignal.timeout(timeoutMs) - const signal = init.signal != null ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal - let method = (init.method ?? 'GET').toUpperCase() - let body = init.body - const headers = headersToRecord(init.headers) + const signal = parts.signal != null ? AbortSignal.any([parts.signal, timeoutSignal]) : timeoutSignal + let method = parts.method.toUpperCase() + let body = parts.body + const headers = parts.headers for (let hop = 0; ; hop++) { const addresses = await resolveAndCheck(current.hostname, opts) - pinned.set(current.hostname, addresses) + const release = pinned.acquire(current.hostname, addresses) let response: UndiciResponse try { @@ -141,22 +249,23 @@ export function createSafeFetch (opts: SafeFetchOptions = {}): typeof fetch { if (timeoutSignal.aborted) { throw new SafeFetchError('TIMEOUT', `Request timed out after ${timeoutMs} ms`, current.href) } - if (init.signal?.aborted === true) throw err + if (parts.signal?.aborted === true) throw err if (err instanceof SafeFetchError) throw err const cause = err instanceof Error ? err.message : String(err) throw new SafeFetchError('FETCH_FAILED', `Request failed: ${cause}`, current.href) + } finally { + // The connection is established once the headers have arrived, so the pin is no longer needed. + release() } - if (!REDIRECT_STATUSES.has(response.status) || mode === 'manual') { - return limitBody(response, maxBodyBytes, current.href) as unknown as Response - } const location = response.headers.get('location') - void response.body?.cancel() - if (mode === 'error') { - throw new SafeFetchError('REDIRECT_NOT_ALLOWED', `Redirect to ${location ?? '?'} not allowed`, current.href) + const isRedirect = REDIRECT_STATUSES.has(response.status) + if (!isRedirect || parts.redirect === 'manual' || location === null) { + return wrapResponse(response, method, maxBodyBytes, current.href, hop > 0, timeoutSignal) } - if (location === null) { - return response as unknown as Response + void response.body?.cancel() + if (parts.redirect === 'error') { + throw new SafeFetchError('REDIRECT_NOT_ALLOWED', `Redirect to ${location} not allowed`, current.href) } if (hop >= maxRedirects) { throw new SafeFetchError('TOO_MANY_REDIRECTS', `More than ${maxRedirects} redirects`, current.href) @@ -172,6 +281,7 @@ export function createSafeFetch (opts: SafeFetchOptions = {}): typeof fetch { if (next.origin !== current.origin) { // Never forward credentials to another origin delete headers.authorization + delete headers['proxy-authorization'] delete headers.cookie } if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) { diff --git a/packages/safe-fetch/src/index.ts b/packages/safe-fetch/src/index.ts index b378abbe57..be0ecb5998 100644 --- a/packages/safe-fetch/src/index.ts +++ b/packages/safe-fetch/src/index.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: EPL-2.0 export { createSafeFetch } from './fetch' -export { BLOCKED_IPV4_RANGES, BLOCKED_IPV6_RANGES, isAllowlistedAddress, isBlockedAddress } from './ranges' +export { BLOCKED_IPV4_RANGES, BLOCKED_IPV6_RANGES, isAllowlistedAddress, isBlockedAddress, validateOptions } from './ranges' export { defaultLookup, resolveAndCheck } from './resolve' export { DEFAULT_MAX_BODY_BYTES, diff --git a/packages/safe-fetch/src/ranges.ts b/packages/safe-fetch/src/ranges.ts index bc5ed2832a..f2117131cf 100644 --- a/packages/safe-fetch/src/ranges.ts +++ b/packages/safe-fetch/src/ranges.ts @@ -29,6 +29,7 @@ export const BLOCKED_IPV6_RANGES: readonly string[] = [ '::/128', // unspecified '::1/128', // loopback '64:ff9b::/96', // NAT64 well-known prefix, blocked as a whole + '64:ff9b:1::/48', // NAT64 local-use prefix (RFC 8215), blocked as a whole '2001::/32', // Teredo, blocked as a whole '2002::/16', // 6to4, blocked as a whole 'fc00::/7', // unique local @@ -65,14 +66,18 @@ function ipv4FromParts (hi: number, lo: number): ipaddr.IPv4 { } /** - * Returns the IPv4 address embedded in the deprecated IPv4-compatible form ::a.b.c.d, if any. - * NAT64, 6to4 and Teredo prefixes are blocked wholesale instead, see BLOCKED_IPV6_RANGES. + * Returns the IPv4 address embedded in the deprecated IPv4-compatible form ::a.b.c.d or in the + * IPv4-translated form ::ffff:0:a.b.c.d (RFC 2765), if any. The IPv4-mapped form ::ffff:a.b.c.d is + * handled by ipaddr.js itself. NAT64, 6to4 and Teredo prefixes are blocked wholesale instead, see + * BLOCKED_IPV6_RANGES. */ function embeddedIpv4 (addr: ipaddr.IPv6): ipaddr.IPv4 | undefined { const parts = addr.parts - const leadingZero = - parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0 - if (leadingZero && (parts[6] !== 0 || parts[7] > 1)) { + const fourZero = parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 + if (fourZero && parts[4] === 0 && parts[5] === 0 && (parts[6] !== 0 || parts[7] > 1)) { + return ipv4FromParts(parts[6], parts[7]) + } + if (fourZero && parts[4] === 0xffff && parts[5] === 0) { return ipv4FromParts(parts[6], parts[7]) } return undefined @@ -123,6 +128,36 @@ export function isAllowlistedAddress (address: string, allowlist: string[] | und return false } +/** + * Checks allowlist and blockedRanges entries once, so an operator's typo fails at start-up rather than + * on the first request. Allowlist entries may be hostnames, `*.suffix` wildcards, IP literals or CIDRs; + * blockedRanges entries must be CIDRs. + */ +export function validateOptions (opts: SafeFetchOptions): void { + for (const entry of opts.blockedRanges ?? []) { + try { + ipaddr.parseCIDR(entry.trim()) + } catch { + throw new Error(`safe-fetch: invalid blockedRanges entry '${entry}', expected a CIDR such as 10.0.0.0/8`) + } + } + for (const entry of opts.allowlist ?? []) { + const item = entry.trim() + if (item === '') { + throw new Error('safe-fetch: empty allowlist entry') + } + if (item.includes('/')) { + try { + ipaddr.parseCIDR(item) + } catch { + throw new Error(`safe-fetch: invalid allowlist CIDR '${entry}'`) + } + } + } + // Warm the parse cache so the per-request path never parses user input again. + parseRanges(opts.blockedRanges) +} + /** * True when the address must not be contacted. Unparseable input is treated as blocked. * Allowlisted addresses are never blocked. diff --git a/services/calendar/pod-calendar/src/config.ts b/services/calendar/pod-calendar/src/config.ts index fe1c85f1c2..ed7cdf9c92 100644 --- a/services/calendar/pod-calendar/src/config.ts +++ b/services/calendar/pod-calendar/src/config.ts @@ -38,7 +38,8 @@ type RequiredKey = | 'InitLimit' | 'WorkspaceInactivityInterval' -const envMap: { [key in keyof Config]-?: string } = { +// GoogleEnabled is derived from Credentials and WATCH_URL and has no environment variable of its own. +const envMap: { [key in Exclude]-?: string } = { Port: 'PORT', AccountsURL: 'ACCOUNTS_URL', @@ -48,8 +49,7 @@ const envMap: { [key in keyof Config]-?: string } = { WATCH_URL: 'WATCH_URL', InitLimit: 'INIT_LIMIT', KvsUrl: 'KVS_URL', - WorkspaceInactivityInterval: 'WORKSPACE_INACTIVITY_INTERVAL', - GoogleEnabled: 'GOOGLE_ENABLED' // derived, not read from the environment + WorkspaceInactivityInterval: 'WORKSPACE_INACTIVITY_INTERVAL' } const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) diff --git a/services/calendar/pod-calendar/src/main.ts b/services/calendar/pod-calendar/src/main.ts index 9cf1306e29..a385e23155 100644 --- a/services/calendar/pod-calendar/src/main.ts +++ b/services/calendar/pod-calendar/src/main.ts @@ -194,6 +194,12 @@ export const main = async (): Promise => { res.status(400).send({ err: "'event' or 'workspace' or 'type' is missing" }) return } + // Outbound sync to Google only. Without the Google module there is nothing to push, and trying + // would take the workspace lock and look up Google secrets on every event write. + if (!config.GoogleEnabled) { + res.send() + return + } void OutcomingClient.push(ctx, accountClient, workspace, event, type).catch((err: any) => { ctx.error('Outcoming sync failed', { eventId: event.eventId, workspace, type, error: err.message }) }) diff --git a/services/calendar/pod-calendar/src/utils.ts b/services/calendar/pod-calendar/src/utils.ts index 9cef9c89b7..f9b3f27b70 100644 --- a/services/calendar/pod-calendar/src/utils.ts +++ b/services/calendar/pod-calendar/src/utils.ts @@ -284,8 +284,8 @@ export function getGoogleClient (): { auth: OAuth2Client google: calendar_v3.Calendar } { - if (config.Credentials === undefined || config.Credentials.trim() === '') { - throw new Error('Google Credentials not provided, the Google Calendar module is disabled') + if (config.Credentials === undefined) { + throw new Error('Google Credentials not provided') } const credentials = JSON.parse(config.Credentials) const { client_secret, client_id, redirect_uris } = credentials.web // eslint-disable-line From 199b3bbfe062cb2ff54762030c1d2514491ccaaa Mon Sep 17 00:00:00 2001 From: UncleDoomVSSP Date: Wed, 7 Oct 2026 11:57:07 +0100 Subject: [PATCH 3/3] style(safe-fetch): wrap export list to the project line width Signed-off-by: UncleDoomVSSP Co-Authored-By: Claude Fable 5.1 --- packages/safe-fetch/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/safe-fetch/src/index.ts b/packages/safe-fetch/src/index.ts index be0ecb5998..883edba952 100644 --- a/packages/safe-fetch/src/index.ts +++ b/packages/safe-fetch/src/index.ts @@ -1,7 +1,13 @@ // SPDX-License-Identifier: EPL-2.0 export { createSafeFetch } from './fetch' -export { BLOCKED_IPV4_RANGES, BLOCKED_IPV6_RANGES, isAllowlistedAddress, isBlockedAddress, validateOptions } from './ranges' +export { + BLOCKED_IPV4_RANGES, + BLOCKED_IPV6_RANGES, + isAllowlistedAddress, + isBlockedAddress, + validateOptions +} from './ranges' export { defaultLookup, resolveAndCheck } from './resolve' export { DEFAULT_MAX_BODY_BYTES,