fix: reach a company calendar on a private address only when its host names the intranet

This commit is contained in:
Linus Rath
2026-11-01 06:35:56 +01:00
parent f007d01c29
commit 0fcdc2ece4
8 changed files with 176 additions and 43 deletions
+17 -4
View File
@@ -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<FetchOutcome> {
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,
});
@@ -162,8 +162,10 @@ export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) {
<div className="space-y-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground" data-testid="org-feed-notes">
<p>
Bulwark&apos;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 &quot;No third-party traffic&quot; on and are listed on Organisation &gt; 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 &quot;No third-party traffic&quot; on and are listed on
Organisation &gt; Security.
</p>
<p>
Each refresh that changed the feed uploads it once to Stalwart for every user who has Calendar open, which counts
+10 -2
View File
@@ -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 });
}));
}
+20 -4
View File
@@ -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);
+49 -17
View File
@@ -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);
});
});
+11 -1
View File
@@ -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: {} })
+10 -3
View File
@@ -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<SettingsPolicy, 'organisation' | 'scopes'>
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<CompanyFeed, 'url'>): 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<CompanyFeed, 'url'>): 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;
}
+55 -10
View File
@@ -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<UndiciRequestInit, 'dispatcher' | 'redirect'> = {},
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<PublicFetchResponse> {
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);