diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts index 3058e129a..78639ab48 100644 --- a/app/api/fetch-ical/route.ts +++ b/app/api/fetch-ical/route.ts @@ -4,7 +4,7 @@ import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart import { DisallowedUrlError, fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard'; import { readBodyCapped } from '@/lib/security/capped-body'; import { configManager } from '@/lib/admin/config-manager'; -import { feedForSubject, feedHostname } from '@/lib/admin/company-feeds'; +import { feedForSubject, intranetFeedHost } from '@/lib/admin/company-feeds'; import { companyFeedCache } from '@/lib/admin/company-feed-cache'; import { serverPolicySubject } from '@/lib/admin/server-policy-subject'; @@ -66,7 +66,7 @@ type FetchOutcome = */ async function fetchIcal( url: string, - options: { allowPrivateHost?: string; etag?: string | null; lastModified?: string | null } = {}, + options: { allowPrivateHost?: string; etag?: string | null; lastModified?: string | null; requireCalendar?: boolean } = {}, ): Promise { const fail = (error: string, status: number): FetchOutcome => ({ kind: 'error', response: NextResponse.json({ error }, { status }) }); const extracted = extractBasicAuth(url); @@ -97,7 +97,9 @@ async function fetchIcal( try { const init = { signal: controller.signal, headers }; - response = options.allowPrivateHost + // The intranet exception holds for the admin's own address only, not + // for a redirect to another port or host. + response = options.allowPrivateHost && new URL(currentUrl).origin === originalOrigin ? await fetchPublicUrl(currentUrl, init, { allowPrivateHost: options.allowPrivateHost }) : await fetchPublicUrl(currentUrl, init); } catch (error) { @@ -138,6 +140,9 @@ async function fetchIcal( if (!buffer) { return { kind: 'error', response: tooLarge(maxResponseSize) }; } + if (options.requireCalendar && !looksLikeCalendar(buffer)) { + return fail('The address did not answer with a calendar', 502); + } return { kind: 'ok', buffer, etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') }; } catch (error: unknown) { if (error instanceof Error && error.name === 'AbortError') { @@ -149,6 +154,13 @@ async function fetchIcal( } } +/** Starts with BEGIN:VCALENDAR (after a byte order mark and blank space). */ +function looksLikeCalendar(buffer: ArrayBuffer): boolean { + // TextDecoder drops a byte order mark; trimStart the rest. + const head = new TextDecoder().decode(buffer.slice(0, 512)).trimStart(); + return /^BEGIN:VCALENDAR/i.test(head); +} + function icalResponse(buffer: ArrayBuffer): NextResponse { return new NextResponse(buffer, { status: 200, @@ -180,7 +192,8 @@ async function fetchCompanyFeed(creds: StalwartCredentials, feedId: string): Pro return icalResponse(cached.body); } const outcome = await fetchIcal(feed.url, { - allowPrivateHost: feedHostname(feed) ?? undefined, + allowPrivateHost: intranetFeedHost(feed) ?? undefined, + requireCalendar: true, etag: cached?.etag, lastModified: cached?.lastModified, }); diff --git a/components/admin/org/sections/company-calendars-section.tsx b/components/admin/org/sections/company-calendars-section.tsx index 7b63a9a34..036c4d0e2 100644 --- a/components/admin/org/sections/company-calendars-section.tsx +++ b/components/admin/org/sections/company-calendars-section.tsx @@ -162,8 +162,10 @@ export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) {

Bulwark's server fetches the feeds, at most once per refresh interval for everyone, and keeps the addresses to - itself: users never see them. A feed may be on your intranet (a private address): only the hosts entered here are - let through. They stay reachable with "No third-party traffic" on and are listed on Organisation > Security. + itself: users never see them. A feed may be on your intranet when its address names it: an intranet IP address + (10.x, 172.16-31.x, 192.168.x), a .local or .internal name, or a name without dots. Loopback and link-local + addresses are never reached. Feeds stay reachable with "No third-party traffic" on and are listed on + Organisation > Security.

Each refresh that changed the feed uploads it once to Stalwart for every user who has Calendar open, which counts diff --git a/integration/tests/22-ent-calendar.spec.ts b/integration/tests/22-ent-calendar.spec.ts index e4b16b767..89deb371e 100644 --- a/integration/tests/22-ent-calendar.spec.ts +++ b/integration/tests/22-ent-calendar.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Browser, type Page } from '@playwright/test'; import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; +import { networkInterfaces } from 'node:os'; import { ENT, ENT_SERVER, adminContext, getPolicy, jmapAs, putPolicy, rawMessage, requireEntEnv, shot, userPage, withPolicy, } from './helpers/ent'; @@ -492,6 +493,8 @@ test('the out-of-office checkbox fits a phone', async ({ browser }) => { }); // ── 9. Company calendars ───────────────────────────────────────────── +// The feed is served from this PC's intranet address, the case of a feed on +// the company intranet. function icsServer(title: string): Promise<{ server: Server; url: string; hits: () => number }> { let hits = 0; @@ -507,9 +510,14 @@ function icsServer(title: string): Promise<{ server: Server; url: string; hits: res.writeHead(200, { 'content-type': 'text/calendar' }); res.end(body); }); - return new Promise((resolve) => server.listen(0, '127.0.0.1', () => { + // An intranet address of this PC: the server reaches admin feeds on the + // intranet, never on loopback. + const lan = Object.values(networkInterfaces()).flat() + .find((i) => i && i.family === 'IPv4' && !i.internal && /^(10.|192.168.|172.(1[6-9]|2d|3[01]).)/.test(i.address))?.address; + if (!lan) throw new Error('no intranet IPv4 address on this PC for the feed server'); + return new Promise((resolve) => server.listen(0, lan, () => { const { port } = server.address() as AddressInfo; - resolve({ server, url: `http://127.0.0.1:${port}/team.ics`, hits: () => hits }); + resolve({ server, url: `http://${lan}:${port}/team.ics`, hits: () => hits }); })); } diff --git a/lib/__tests__/fetch-ical-feed-route.test.ts b/lib/__tests__/fetch-ical-feed-route.test.ts index 3de311eca..f437f3526 100644 --- a/lib/__tests__/fetch-ical-feed-route.test.ts +++ b/lib/__tests__/fetch-ical-feed-route.test.ts @@ -54,7 +54,7 @@ const POLICY = { restrictions: {}, features: {}, defaults: {}, themePolicy: {}, organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=t', color: '#2563eb', refreshMinutes: 60 }] } }, scopes: [ - { id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://cal.intranet.example/team.ics', refreshMinutes: 120 }] } } } }, + { id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://calendar.corp.internal/team.ics', refreshMinutes: 120 }] } } } }, { id: 'bob', label: 'Bob', match: { groups: ['ent-bob@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'bobs', name: 'Bob', url: 'https://cal.example.org/b.ics' }] } } } }, ], }; @@ -90,9 +90,11 @@ describe('POST /api/fetch-ical { feedId }', () => { expect((await post({ feedId: 'hol' })).status).toBe(200); expect(guardedFetch.mock.calls[0][0]).toBe('https://cal.example.org/h.ics?token=t'); expect((await post({ feedId: 'team' })).status).toBe(200); - // The admin's own intranet host may be private, and only it. - expect(guardedFetch.mock.calls[1][0]).toBe('http://cal.intranet.example/team.ics'); - expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'cal.intranet.example' }); + // The admin's own intranet host may be private, and only it; a public + // host gets no exception. + expect(guardedFetch.mock.calls[0]).toHaveLength(2); + expect(guardedFetch.mock.calls[1][0]).toBe('http://calendar.corp.internal/team.ics'); + expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'calendar.corp.internal' }); }); it('refuses a feed of a scope the user is not in, without fetching anything', async () => { @@ -122,6 +124,20 @@ describe('POST /api/fetch-ical { feedId }', () => { expect(guardedFetch.mock.calls[1][1].headers).toMatchObject({ 'If-None-Match': '"v1"', 'If-Modified-Since': 'Mon, 02 Nov 2026 07:00:00 GMT' }); }); + it('keeps the intranet exception off a redirect to another port', async () => { + guardedFetch + .mockImplementationOnce(async () => new Response(null, { status: 302, headers: { location: 'http://calendar.corp.internal:8443/x' } })) + .mockImplementationOnce(async () => ics()); + expect((await post({ feedId: 'team' })).status).toBe(200); + expect(guardedFetch.mock.calls[0][2]).toEqual({ allowPrivateHost: 'calendar.corp.internal' }); + expect(guardedFetch.mock.calls[1]).toHaveLength(2); + }); + + it('refuses an answer that is not a calendar', async () => { + guardedFetch.mockImplementation(async () => new Response('{"secret":1}', { status: 200, headers: { 'content-type': 'application/json' } })); + expect((await post({ feedId: 'team' })).status).toBe(502); + }); + it('needs a signed-in user', async () => { creds.value = null; expect((await post({ feedId: 'hol' })).status).toBe(401); diff --git a/lib/__tests__/url-guard-private-host.test.ts b/lib/__tests__/url-guard-private-host.test.ts index 2ba620485..7cb1ac942 100644 --- a/lib/__tests__/url-guard-private-host.test.ts +++ b/lib/__tests__/url-guard-private-host.test.ts @@ -3,10 +3,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; -// A company calendar may live on the intranet: fetchPublicUrl lets exactly -// the host the admin entered resolve to a private address, and nothing else -// (a redirect to another private host, another name), with the address still -// pinned at connect time. +// A company calendar may live on the intranet: fetchPublicUrl lets the one +// host the admin entered resolve to an intranet address, and only when that +// host itself names the intranet (an intranet address, .local/.internal, a +// single-label name). Loopback, link-local and metadata addresses stay +// refused for it, a public name gets no exception at all, and the address is +// still pinned at connect time. const lookup = vi.fn(); vi.mock('node:dns/promises', async (importOriginal) => { @@ -18,6 +20,24 @@ vi.mock('node:dns/promises', async (importOriginal) => { }; }); +describe('what counts as the intranet', () => { + it('takes intranet addresses and names, never loopback, link-local, metadata or public names', async () => { + const { isIntranetAddress, isIntranetHostName } = await import('@/lib/security/url-guard'); + for (const ip of ['10.1.2.3', '172.16.0.9', '192.168.178.20', '100.64.1.1', 'fd12:3456::1', '::ffff:10.0.0.1']) { + expect(isIntranetAddress(ip), ip).toBe(true); + } + for (const ip of ['127.0.0.1', '169.254.169.254', '0.0.0.0', '::1', 'fe80::1', 'fd00:ec2::254', '::ffff:127.0.0.1', '8.8.8.8', '224.0.0.1']) { + expect(isIntranetAddress(ip), ip).toBe(false); + } + for (const host of ['calendar', 'cal.corp.internal', 'nas.local', 'srv.localdomain', '10.0.0.5', '[fd12::1]']) { + expect(isIntranetHostName(host), host).toBe(true); + } + for (const host of ['cal.example.org', 'localhost', 'app.localhost', '127.0.0.1', '169.254.169.254', '']) { + expect(isIntranetHostName(host), host).toBe(false); + } + }); +}); + describe('fetchPublicUrl with allowPrivateHost', () => { let server: Server; let port: number; @@ -37,30 +57,42 @@ describe('fetchPublicUrl with allowPrivateHost', () => { }); beforeEach(() => { lookup.mockReset(); - lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); hits = 0; }); - it('reaches the admin\'s intranet host', async () => { - const { fetchPublicUrl } = await import('@/lib/security/url-guard'); - const res = await fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`, {}, { allowPrivateHost: 'cal.intranet.example' }); - expect(res.status).toBe(200); - expect(hits).toBe(1); + it('lets an intranet name through to an intranet address', async () => { + lookup.mockResolvedValue([{ address: '10.255.255.1', family: 4 }]); + const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); + // Nothing answers there: the connection is tried (the guard lets it + // through) and runs out of time. + const error = await fetchPublicUrl('http://calendar:8080/team.ics', { signal: AbortSignal.timeout(300) }, { allowPrivateHost: 'calendar' }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(DisallowedUrlError); + expect(lookup).toHaveBeenCalled(); }); - it('allows .internal names only for that host', async () => { + it('refuses loopback and metadata addresses even for the admin\'s intranet host', async () => { const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); - const res = await fetchPublicUrl(`http://calendar.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' }); - expect(res.status).toBe(200); - await expect(fetchPublicUrl(`http://other.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' })) + lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); + await expect(fetchPublicUrl(`http://calendar:${port}/team.ics`, {}, { allowPrivateHost: 'calendar' })) .rejects.toBeInstanceOf(DisallowedUrlError); + lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]); + await expect(fetchPublicUrl('http://cal.corp.internal/latest/meta-data/', {}, { allowPrivateHost: 'cal.corp.internal' })) + .rejects.toBeInstanceOf(DisallowedUrlError); + await expect(fetchPublicUrl(`http://127.0.0.1:${port}/team.ics`, {}, { allowPrivateHost: '127.0.0.1' })) + .rejects.toBeInstanceOf(DisallowedUrlError); + expect(hits).toBe(0); }); - it('keeps refusing every other host that resolves to a private address', async () => { + it('gives a public host no exception, whatever its DNS points at', async () => { const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard'); - await expect(fetchPublicUrl(`http://rebind.example:${port}/cal.ics`, {}, { allowPrivateHost: 'cal.intranet.example' })) + lookup.mockResolvedValue([{ address: '10.0.0.7', family: 4 }]); + await expect(fetchPublicUrl('http://partner.example.org/cal.ics', {}, { allowPrivateHost: 'partner.example.org' })) + .rejects.toBeInstanceOf(DisallowedUrlError); + // Another intranet name than the admin's stays refused. + await expect(fetchPublicUrl('http://other.corp.internal/a.ics', {}, { allowPrivateHost: 'cal.corp.internal' })) .rejects.toBeInstanceOf(DisallowedUrlError); - await expect(fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`)).rejects.toBeInstanceOf(DisallowedUrlError); expect(hits).toBe(0); }); }); diff --git a/lib/admin/__tests__/company-feeds.test.ts b/lib/admin/__tests__/company-feeds.test.ts index 77a579132..daffdb154 100644 --- a/lib/admin/__tests__/company-feeds.test.ts +++ b/lib/admin/__tests__/company-feeds.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { allFeeds, feedForSubject, stripFeedUrls } from '../company-feeds'; +import { allFeeds, feedForSubject, intranetFeedHost, stripFeedUrls } from '../company-feeds'; import { listReachableHosts } from '../reachable-hosts'; import { sanitizeOrganisationPolicy } from '../organisation-policy'; import type { SettingsPolicy } from '../types'; @@ -32,6 +32,16 @@ describe('company feeds on the server', () => { expect(feedForSubject(policy, { email: 'b@two.example', groups: [] }, 'two')?.name).toBe('Two'); }); + it('lifts the private-address block only for a host that names the intranet', () => { + expect(intranetFeedHost({ url: 'http://calendar.corp.internal/team.ics' })).toBe('calendar.corp.internal'); + expect(intranetFeedHost({ url: 'http://intranet/team.ics' })).toBe('intranet'); + expect(intranetFeedHost({ url: 'http://10.1.2.3/team.ics' })).toBe('10.1.2.3'); + expect(intranetFeedHost({ url: 'https://cal.example.org/h.ics' })).toBeNull(); + expect(intranetFeedHost({ url: 'http://127.0.0.1:8080/h.ics' })).toBeNull(); + expect(intranetFeedHost({ url: 'http://169.254.169.254/latest' })).toBeNull(); + expect(intranetFeedHost({ url: 'http://localhost/h.ics' })).toBeNull(); + }); + it('lists every scope\'s feed host as reachable', () => { expect(allFeeds(policy).map((f) => f.id)).toEqual(['hol', 'team', 'two']); const hosts = listReachableHosts({ get: () => undefined, policy, env: {} }) diff --git a/lib/admin/company-feeds.ts b/lib/admin/company-feeds.ts index 306f17076..03121fef0 100644 --- a/lib/admin/company-feeds.ts +++ b/lib/admin/company-feeds.ts @@ -11,6 +11,7 @@ import type { SettingsPolicy } from './types'; import type { CompanyFeed } from './organisation-policy'; import { resolvePolicy, type PolicySubject } from './policy-scopes'; +import { isIntranetHostName } from '@/lib/security/url-guard'; function withoutUrls(organisation: unknown): unknown { if (!organisation || typeof organisation !== 'object') return organisation; @@ -55,10 +56,16 @@ export function allFeeds(policy: Pick return lists.flatMap((feeds) => (Array.isArray(feeds) ? feeds.filter((f): f is CompanyFeed => !!f && typeof f === 'object' && typeof (f as CompanyFeed).url === 'string') : [])); } -/** The hostnames of the admin's feeds: the only private hosts a feed fetch may reach. */ -export function feedHostname(feed: Pick): string | null { +/** + * The feed's host when it names the intranet (an intranet address, a + * .local/.internal/.localdomain name or a single-label name): the only kind + * of host a feed fetch may reach on a private address. Null for public + * names, so a public host whose DNS is later pointed inside reaches nothing. + */ +export function intranetFeedHost(feed: Pick): string | null { try { - return new URL(feed.url).hostname.toLowerCase(); + const host = new URL(feed.url).hostname.toLowerCase(); + return isIntranetHostName(host) ? host : null; } catch { return null; } diff --git a/lib/security/url-guard.ts b/lib/security/url-guard.ts index 2a60ed429..3f62988f2 100644 --- a/lib/security/url-guard.ts +++ b/lib/security/url-guard.ts @@ -10,7 +10,7 @@ import { type RequestInit as UndiciRequestInit, type Response as UndiciResponse, } from 'undici'; -import { isPrivateAddress } from './ip-ranges'; +import { isPrivateAddress, parseV6Groups } from './ip-ranges'; const BLOCKED_HOSTNAMES = new Set(['localhost']); const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain']; @@ -29,6 +29,41 @@ function isBlockedHostname(hostname: string): boolean { return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); } +/** + * An intranet address: RFC 1918, CGNAT or an IPv6 unique local address, + * as such or IPv4-mapped. Never loopback, link-local (cloud metadata lives + * there), unspecified, multicast or the AWS metadata ULA. + */ +export function isIntranetAddress(ip: string): boolean { + const host = normalizeHostname(ip); + const v4 = (a: string) => /^10\./.test(a) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(a) || /^192\.168\./.test(a) + || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a); + const family = isIP(host); + if (family === 4) return v4(host); + if (family !== 6) return false; + const g = parseV6Groups(host); + if (!g) return false; + if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) { + return v4(`${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`); + } + const awsMetadata = g[0] === 0xfd00 && g[1] === 0x0ec2 && g.slice(2, 7).every((x) => x === 0) && g[7] === 0x254; + return (g[0] & 0xfe00) === 0xfc00 && !awsMetadata; +} + +/** + * Whether an admin-entered host names the intranet: an intranet address + * literal, a name under a private suffix (.local, .internal, .localdomain) + * or a single-label name. Public names and localhost never are, so a public + * host whose DNS later points inside can't be used to reach it. + */ +export function isIntranetHostName(hostname: string): boolean { + const host = normalizeHostname(hostname); + if (!host || host === 'localhost' || host.endsWith('.localhost')) return false; + if (isIP(host)) return isIntranetAddress(host); + if (['.local', '.internal', '.localdomain'].some((suffix) => host.endsWith(suffix))) return true; + return !host.includes('.'); +} + /** * Synchronous part of the guard: protocol, embedded credentials, blocked * hostnames / suffixes, and literal IP addresses. Returns the parsed URL when @@ -46,7 +81,7 @@ function parseAllowedUrl(urlString: string, privateHost?: string): URL | null { if (url.username || url.password) return null; const hostname = normalizeHostname(url.hostname); - if (privateHost && hostname === privateHost) return url; + if (privateHost && hostname === privateHost && isIntranetHostName(hostname)) return url; if (isBlockedHostname(hostname)) return null; if (isBlockedIpAddress(hostname)) return null; @@ -215,10 +250,12 @@ export function envProxyFor(url: URL): URL | null { } /** - * guardedLookup, except that `privateHost` may resolve to a private address: - * an intranet host the admin entered themselves (company calendars). Every - * other name, redirect targets included, is still checked, and the address - * is still the one the socket connects to. + * guardedLookup, except that `privateHost` may resolve to an intranet + * address (isIntranetAddress): a host on the intranet the admin entered + * themselves (company calendars). Loopback, link-local and metadata + * addresses stay refused for it too; every other name, redirect targets + * included, is checked as always; and the address is still the one the + * socket connects to. */ function lookupAllowing(privateHost: string): typeof guardedLookup { return (hostname, options, callback) => { @@ -236,6 +273,11 @@ function lookupAllowing(privateHost: string): typeof guardedLookup { cb(err); return; } + const refused = records.find((record) => isBlockedIpAddress(record.address) && !isIntranetAddress(record.address)); + if (refused) { + cb(new BlockedAddressError(hostname, refused.address)); + return; + } if (opts.all) cb(null, records); else cb(null, records[0].address, records[0].family); }) @@ -341,14 +383,17 @@ export async function fetchPublicUrl( init: Omit = {}, options: { /** - * One hostname that may be a private (intranet) host: only for an - * address the admin entered (company calendars). It must equal the - * URL's hostname to make any difference. + * One hostname on the intranet (isIntranetHostName) that may resolve to + * an intranet address: only for an address the admin entered (company + * calendars). It must equal the URL's hostname to make any difference; + * a public name here changes nothing. */ allowPrivateHost?: string; } = {}, ): Promise { - const privateHost = options.allowPrivateHost ? normalizeHostname(options.allowPrivateHost) : undefined; + const privateHost = options.allowPrivateHost && isIntranetHostName(options.allowPrivateHost) + ? normalizeHostname(options.allowPrivateHost) + : undefined; const parsed = parseAllowedUrl(url, privateHost); if (!parsed) { throw new DisallowedUrlError(url);