diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx index 1030bf9c6..9507b774f 100644 --- a/app/(main)/admin/layout.tsx +++ b/app/(main)/admin/layout.tsx @@ -159,7 +159,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) return; } - router.replace('/admin/login'); + // Carry the page that was asked for through the login, so a connector + // link to a panel (/connector/admin_extension?slug=x) does not dump + // the admin on the dashboard after signing in. + const next = encodeURIComponent(`${pathname}${window.location.search}`); + router.replace(`/admin/login?next=${next}`); } catch (err) { if (cancelled) return; setAuthError(err instanceof Error ? err.message : 'Network error during admin check'); diff --git a/app/(main)/admin/login/page.tsx b/app/(main)/admin/login/page.tsx index 938b1d7ee..3e870475b 100644 --- a/app/(main)/admin/login/page.tsx +++ b/app/(main)/admin/login/page.tsx @@ -6,6 +6,8 @@ import { Shield } from '@/components/icons'; import { useConfig } from '@/hooks/use-config'; import { useThemeStore } from '@/stores/theme-store'; import { apiFetch, withBasePath } from '@/lib/browser-navigation'; +import { safeAdminNext } from '@/lib/connector/admin-next'; +import { isAdminTab, useAdminTabStore } from '@/stores/admin-tab-store'; export default function AdminLoginPage() { const router = useRouter(); @@ -35,6 +37,18 @@ export default function AdminLoginPage() { return; } + // A connector link to an admin panel parks its destination in ?next= + // so logging in lands on the panel that was clicked, not the dashboard. + // safeAdminNext() is the gate: it is the only path here that comes out + // of a URL rather than out of a fixed template. + const next = safeAdminNext(new URLSearchParams(window.location.search).get('next')); + if (next) { + const [path, query] = next.split('?', 2); + const tab = new URLSearchParams(query ?? '').get('tab'); + if (tab && isAdminTab(tab)) useAdminTabStore.getState().setActiveTab(tab); + router.push(path); + return; + } router.push('/admin'); } catch { setError('Network error. Please try again.'); diff --git a/app/(main)/connector/[target]/page.tsx b/app/(main)/connector/[target]/page.tsx new file mode 100644 index 000000000..62c6694b6 --- /dev/null +++ b/app/(main)/connector/[target]/page.tsx @@ -0,0 +1,27 @@ +import { ConnectorClient } from '@/components/connector/connector-client'; +import { TARGET_NAMES } from '@/lib/connector/registry'; +import { IS_LITE } from '@/lib/lite'; + +// Where a connector link lands: /connector/?, resolved to a +// real path inside the app by lib/connector/registry.ts. +// +// Outside [locale], like /protocol and /admin, so a published link never +// carries a locale it has no business choosing. Next matches the static +// `connector` segment before the `[locale]` one, and proxy.ts keeps next-intl +// from rewriting it. +// +// The target segment is all the server sees. The parameters are read from the +// address bar in the client, which is also what makes this work in the static +// Lite export, where there is no server to read them. +export const generateStaticParams = IS_LITE + ? () => TARGET_NAMES.map((target) => ({ target })) + : undefined; + +export default async function ConnectorRoute({ + params, +}: { + params: Promise<{ target: string }>; +}) { + const { target } = await params; + return ; +} diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts new file mode 100644 index 000000000..87de75fb9 --- /dev/null +++ b/app/api/connector/capabilities/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server'; +import { configManager } from '@/lib/admin/config-manager'; +import { TARGET_NAMES } from '@/lib/connector/registry'; + +/** + * What a connector asks an instance about itself. + * + * Someone adding this instance at connector.bulwarkmail.org gets a request + * here, from their own browser, straight to their own server, with no + * credentials. It answers two questions: is this really a Bulwark, and which + * connector links can it resolve - so a link to a target this version does not + * have can warn before it opens instead of after. + * + * Unauthenticated on purpose: the browser making the probe has no session + * here, and the answer is narrower than the login page it sits next to. It + * says nothing about accounts, domains or the mail server - only the product, + * its version, the branded app name, and the target names, which are a + * compile-time constant. + * + * `appName` can be a customer's own branding, so the whole endpoint is gated + * on `connectorEnabled` for deployments that would rather disclose nothing. + * + * Bulwark Lite has no route handlers; scripts/lite/postbuild.mjs writes the + * same document to /connector.json, and the connector tries this first and + * falls back to that. + */ +export async function GET() { + await configManager.ensureLoaded(); + + if (!configManager.get('connectorEnabled', true)) { + return new NextResponse(null, { status: 404 }); + } + + return NextResponse.json( + { + product: 'bulwark-webmail', + appName: + configManager.get('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Bulwark', + version: process.env.NEXT_PUBLIC_APP_VERSION || null, + connectorPath: '/connector', + targets: TARGET_NAMES, + }, + { + headers: { + // The probe is cross-origin by definition - it comes from the + // connector's origin, or from any other site someone points at their + // instance. Nothing here is per-user, so there is nothing for a + // wildcard to leak; credentials are never read. + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'public, max-age=300', + }, + }, + ); +} diff --git a/components/connector/connector-client.tsx b/components/connector/connector-client.tsx new file mode 100644 index 000000000..19a8b0537 --- /dev/null +++ b/components/connector/connector-client.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { resolveTarget, type ResolveResult } from '@/lib/connector/registry'; +import { appPath } from '@/lib/deep-links'; +import { replaceWindowLocation } from '@/lib/browser-navigation'; +import { useAdminTabStore } from '@/stores/admin-tab-store'; + +const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ''; + +/** + * Resolves a connector link and sends the browser to the real surface. + * + * Everything past the target name is read from `window.location` rather than + * handed down as a route prop: the Lite build is a static export with no + * server to read a query string, and on a server build it keeps the target out + * of the request the origin sees. + * + * It replaces rather than pushes, so Back returns to wherever the link was + * clicked instead of bouncing through here again. + * + * Authentication needs nothing special. The surface the user lands on does its + * own `saveRedirectAfterLogin()` + `redirectToLogin()`, and by then the + * address bar already holds the resolved path, so the login round-trip returns + * to the deep link. The admin area is the exception - see `?next=` below. + */ +export function ConnectorClient({ target }: { target: string }) { + const [result, setResult] = useState(null); + const setAdminTab = useAdminTabStore((s) => s.setActiveTab); + + useEffect(() => { + setResult(resolveTarget(target, new URLSearchParams(window.location.search))); + }, [target]); + + const destination = useMemo(() => { + if (result?.kind !== 'ok') return null; + const { path, adminTab } = result.resolution; + // The admin shell keeps its active tab in a store rather than in the URL, + // so a link to a specific panel selects it here. The store is persisted, + // so the choice survives an admin login in between. + return { url: appPath(path), adminTab: adminTab ?? null }; + }, [result]); + + useEffect(() => { + if (!destination) return; + if (destination.adminTab) setAdminTab(destination.adminTab); + // Nothing is appended to the URL. An admin who is not signed in gets + // bounced by the admin layout, which parks this destination in its own + // `?next=` - adding one here would nest inside that one and be refused. + replaceWindowLocation(destination.url); + }, [destination, setAdminTab]); + + if (result === null || result.kind === 'ok') { + return Opening…; + } + + // Neither case 404s. A link that this version cannot honour should say so + // and offer the app, not look like a broken server. + if (result.kind === 'unknown-target') { + return ( + +

This link is newer than this Bulwark

+

+ It opens something {APP_VERSION ? `version ${APP_VERSION} ` : 'this version '} + does not know about. Updating Bulwark should teach it. +

+ +
+ ); + } + + return ( + +

That link is not quite right

+

+ It asks for {result.target.label}, but its{' '} + {/* The parameter is named, never echoed: its value is attacker-controlled. */} + {result.param} is missing or + malformed. Nothing was opened. +

+ +
+ ); +} + +function Actions() { + return ( +

+ + Open Bulwark + +

+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/lib/__tests__/connector-registry.test.ts b/lib/__tests__/connector-registry.test.ts new file mode 100644 index 000000000..fd8a334f7 --- /dev/null +++ b/lib/__tests__/connector-registry.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { TARGETS, TARGET_NAMES, getTarget, resolveTarget } from '@/lib/connector/registry'; +import { safeAdminNext } from '@/lib/connector/admin-next'; + +const resolve = (name: string, search: Record = {}) => + resolveTarget(name, search); + +const path = (name: string, search: Record = {}) => { + const result = resolve(name, search); + if (result.kind !== 'ok') throw new Error(`${name} did not resolve: ${result.kind}`); + return result.resolution.path; +}; + +describe('the registry', () => { + it('has unique names that are safe as one path segment', () => { + expect(new Set(TARGET_NAMES).size).toBe(TARGET_NAMES.length); + for (const name of TARGET_NAMES) expect(name).toMatch(/^[a-z][a-z0-9_]*$/); + }); + + it('labels every target for the "unknown link" card', () => { + for (const target of TARGETS) expect(target.label.length).toBeGreaterThan(0); + }); + + it('does not inherit names from Object.prototype', () => { + for (const name of ['constructor', '__proto__', 'toString']) { + expect(getTarget(name)).toBeNull(); + expect(resolve(name).kind).toBe('unknown-target'); + } + }); + + it('reports an unknown target rather than throwing or 404ing', () => { + expect(resolve('plugin_sandbox_settings').kind).toBe('unknown-target'); + expect(resolveTarget(null, {}).kind).toBe('unknown-target'); + expect(resolveTarget('', {}).kind).toBe('unknown-target'); + }); +}); + +describe('resolving to app paths', () => { + it('resolves the bare surfaces', () => { + expect(path('app')).toBe('/mail'); + expect(path('contacts')).toBe('/contacts'); + expect(path('settings')).toBe('/settings'); + expect(path('setup')).toBe('/setup'); + }); + + it('resolves a settings tab', () => { + expect(path('settings', { tab: 'filters' })).toBe('/settings/filters'); + }); + + it('resolves a calendar view and date', () => { + expect(path('calendar')).toBe('/calendar/month'); + expect(path('calendar', { view: 'week' })).toBe('/calendar/week'); + expect(path('calendar', { view: 'day', date: '2026-09-20' })).toBe('/calendar/day/2026-09-20'); + }); + + it('resolves a mail folder by alias and by opaque id', () => { + expect(path('mail_folder', { ref: 'inbox' })).toBe('/mail/folder/inbox'); + expect(path('mail_folder', { ref: 'a1b2c3' })).toBe('/mail/folder/a1b2c3'); + expect(path('mail_folder')).toBe('/mail'); + }); + + it('resolves a drive path', () => { + expect(path('files', { path: 'Documents/Invoices' })).toBe('/files/Documents/Invoices'); + expect(path('files')).toBe('/files'); + }); + + it('marks the admin targets, and carries the tab the shell keeps in a store', () => { + const admin = resolve('admin', { tab: 'policy' }); + expect(admin.kind).toBe('ok'); + if (admin.kind === 'ok') { + expect(admin.resolution).toEqual({ path: '/admin', adminTab: 'policy', admin: true }); + } + + const auth = resolve('admin_auth'); + if (auth.kind === 'ok') expect(auth.resolution.adminTab).toBe('auth'); + + const ext = resolve('admin_extension', { slug: 'quick-reply' }); + if (ext.kind === 'ok') { + expect(ext.resolution).toEqual({ path: '/admin/marketplace/quick-reply', admin: true }); + } + }); + + it('defaults the admin tab when the link named none', () => { + const admin = resolve('admin'); + if (admin.kind === 'ok') expect(admin.resolution.adminTab).toBe('dashboard'); + }); + + it('leaves the user targets unmarked, so they take the normal login path', () => { + for (const name of ['app', 'settings', 'calendar', 'contacts', 'files', 'mail_folder']) { + const result = resolve(name); + if (result.kind === 'ok') expect(result.resolution.admin).toBeUndefined(); + } + }); +}); + +describe('parameter validation', () => { + it('refuses a settings tab that is not a real tab', () => { + const result = resolve('settings', { tab: 'root' }); + expect(result.kind).toBe('bad-param'); + if (result.kind === 'bad-param') expect(result.param).toBe('tab'); + }); + + it('requires a required parameter', () => { + expect(resolve('admin_extension').kind).toBe('bad-param'); + expect(resolve('admin_extension', { slug: '' }).kind).toBe('bad-param'); + }); + + it.each([ + '../../etc/passwd', + '//evil.example', + 'Quick-Reply', + 'quick reply', + 'javascript:alert(1)', + 'a'.repeat(65), + ])('refuses the extension slug %j', (slug) => { + expect(resolve('admin_extension', { slug }).kind).toBe('bad-param'); + }); + + it.each(['../admin', 'a/b', 'a\\b', 'a?b', 'a#b', '.', '..'])( + 'refuses the mailbox ref %j', + (ref) => { + expect(resolve('mail_folder', { ref }).kind).toBe('bad-param'); + }, + ); + + it.each(['2026-02-31', '2026-13-01', '20260920', 'tomorrow'])( + 'refuses the date %j', + (date) => { + expect(resolve('calendar', { date }).kind).toBe('bad-param'); + }, + ); + + it.each(['../secrets', 'Documents/../../etc', 'a?b'])('refuses the drive path %j', (p) => { + expect(resolve('files', { path: p }).kind).toBe('bad-param'); + }); + + it('refuses an over-long value whatever its shape', () => { + expect(resolve('mail_folder', { ref: 'a'.repeat(513) }).kind).toBe('bad-param'); + }); + + it('drops a parameter the target does not declare, rather than forwarding it', () => { + // A connector newer than this instance may carry something extra; the link + // should still open the surface it named. + expect(path('settings', { tab: 'filters', redirect: 'https://evil.example' })).toBe( + '/settings/filters', + ); + }); + + it('never lets a parameter escape its path segment', () => { + for (const hostile of ['../admin', '..%2Fadmin', 'a/../../b']) { + const result = resolve('mail_folder', { ref: hostile }); + if (result.kind === 'ok') { + // If it was accepted at all, it must still be a single segment. + const tail = result.resolution.path.replace('/mail/folder/', ''); + expect(tail).not.toContain('/'); + expect(decodeURIComponent(tail)).not.toContain('..'); + } + } + }); +}); + +describe('safeAdminNext', () => { + it.each([ + ['/admin', '/admin'], + ['/admin/', '/admin/'], + ['/admin/marketplace/quick-reply', '/admin/marketplace/quick-reply'], + ['/admin?tab=policy', '/admin?tab=policy'], + ['%2Fadmin%2Fplugins%2Fx', '/admin/plugins/x'], + ])('accepts %j', (raw, expected) => { + expect(safeAdminNext(raw)).toBe(expected); + }); + + it.each([ + null, + undefined, + '', + '/mail', + '/settings/filters', + '//evil.example', + '/\\evil.example', + 'https://evil.example', + 'javascript:alert(1)', + '/admin/../mail', + '/adminfoo', + '/admin?tab=policy&x=1', + '/admin?next=https://evil.example', + '%2f%2fevil.example', + '/admin/' + 'a'.repeat(600), + ])('refuses %j', (raw) => { + expect(safeAdminNext(raw as string | null)).toBeNull(); + }); +}); + +describe('parity with the connector', () => { + // The connector (github.com/bulwarkmail/connector) owns the names and the + // parameter schemas; this side owns the paths. When both checkouts are + // present, the names must match exactly - a target this side cannot resolve + // would be a published link that 404s, and one the connector never emits is + // dead code here. + const connectorRegistry = join(process.cwd(), 'repos', 'connector', 'src', 'lib', 'registry.ts'); + + it.skipIf(!existsSync(connectorRegistry))('names the same targets', () => { + const source = readFileSync(connectorRegistry, 'utf8'); + const theirs = [...source.matchAll(/^\s{4}name: "([a-z0-9_]+)",$/gm)].map((m) => m[1]); + expect(theirs.length).toBeGreaterThan(0); + expect([...theirs].sort()).toEqual([...TARGET_NAMES].sort()); + }); + + it.skipIf(!existsSync(connectorRegistry))( + 'never names a target after a path the connector site owns', + () => { + const source = readFileSync(connectorRegistry, 'utf8'); + const reserved = /RESERVED_PATHS = \[([^\]]+)\]/ + .exec(source)?.[1] + .match(/"([a-z-]+)"/g) + ?.map((s) => s.replace(/"/g, '')) ?? []; + expect(reserved.length).toBeGreaterThan(0); + for (const name of TARGET_NAMES) expect(reserved).not.toContain(name); + }, + ); +}); diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 21fb977a3..c09f8ba84 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -265,6 +265,15 @@ export const CONFIG_ENV_MAP: Record/hosting/discovery` unless the URL already carries a path. diff --git a/lib/connector/admin-next.ts b/lib/connector/admin-next.ts new file mode 100644 index 000000000..3f580b050 --- /dev/null +++ b/lib/connector/admin-next.ts @@ -0,0 +1,51 @@ +/** + * The `?next=` round-trip for the admin area. + * + * A connector link to an admin panel lands on `/admin/...` with no admin + * session, the layout bounces to `/admin/login`, and without this the admin + * would log in and arrive at the dashboard instead of the extension they + * clicked Install on. + * + * This is the one place in the whole connector flow where a *path* comes out + * of a URL rather than out of a fixed template, so it gets the strictest + * check in it. Everything that is not unmistakably an admin path inside this + * app is thrown away and the login falls back to `/admin`. + */ + +const SAFE = /^\/admin(?:\/[A-Za-z0-9._~-]+)*\/?$/; + +/** + * Returns a path safe to `router.replace()` after login, or null. + * + * Refuses anything that could leave this origin or this area: a scheme, a + * protocol-relative `//host`, a backslash (which some browsers normalise to + * `/`), an encoded separator, a `..` segment, or a path outside `/admin`. + * A `?tab=` query is allowed, and validated by the caller against the real + * tab list - the admin shell keeps its tab in a store, so that is how a link + * reaches a specific panel. + */ +export function safeAdminNext(raw: string | null | undefined): string | null { + if (!raw) return null; + + let value = raw; + // One decode, so `%2f%2fevil` cannot smuggle a separator past the checks + // below. A value that is not valid percent-encoding is not a path we wrote. + try { + value = decodeURIComponent(raw); + } catch { + return null; + } + + if (value.length > 512) return null; + if (value.includes('\\') || value.includes('\0')) return null; + if (value.startsWith('//')) return null; + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null; + + const [path, query = ''] = value.split('?', 2); + if (!SAFE.test(path)) return null; + if (path.split('/').includes('..')) return null; + // Only the tab hand-off, and only as a plain word. + if (query && !/^tab=[a-z_]{1,32}$/.test(query)) return null; + + return query ? `${path}?${query}` : path; +} diff --git a/lib/connector/registry.ts b/lib/connector/registry.ts new file mode 100644 index 000000000..9b3b02a59 --- /dev/null +++ b/lib/connector/registry.ts @@ -0,0 +1,250 @@ +/** + * The instance half of the connector contract. + * + * A connector link names a destination, not a host: + * + * https://connector.bulwarkmail.org/settings?tab=filters + * + * The connector (repos/connector, github.com/bulwarkmail/connector) resolves + * that against whichever instance the reader's browser has stored, and sends + * them to `/connector/settings?tab=filters`. This file is what + * happens next: it turns a target name plus its parameters into a real path + * inside this app. + * + * The split is deliberate. The connector knows the *names* and the parameter + * schemas; only this file knows the *paths*. So a route can move here without + * breaking a link that was published a year ago, and an instance older than + * the connector renders a "this version doesn't know that link" card instead + * of a 404. + * + * `TARGET_NAMES` and `PARAMS` mirror `src/lib/registry.ts` in the connector. + * `__tests__/connector-registry.test.ts` checks the halves agree; when they + * diverge, this side is the one that must not silently accept something it + * cannot resolve. + * + * Nothing here interpolates raw input into a path. Every parameter is checked + * against its declared type first, and the templates are fixed strings - there + * is deliberately no parameter that carries a path, a URL, or a redirect. + */ + +import { ADMIN_TABS, type AdminTabId } from '@/stores/admin-tab-store'; +import type { SettingsSearchTab } from '@/lib/settings-search'; +import { buildSettingsPath, buildFilesPath } from '@/lib/deep-links'; + +/** Mirrors `SettingsSearchTab`. Listed so an unknown tab is refused here. */ +const SETTINGS_TABS: readonly SettingsSearchTab[] = [ + 'account', 'language', 'notifications', 'appearance', 'layout', 'reading', + 'composing', 'downloads', 'identities', 'vacation', 'filters', 'templates', + 'folders', 'keywords', 'security', 'content_senders', 'calendar', 'contacts', + 'files', 'protocol_handlers', 'sidebar_apps', 'about_data', 'themes', + 'plugins', 'debug', +]; + +const CALENDAR_VIEWS = ['month', 'week', 'day', 'agenda', 'tasks'] as const; + +// A mailbox ref is validated as an opaque id here, not against a list: the +// readable aliases (`inbox`, `unified-sent`, ...) are resolved by +// `resolveFolderRef` in lib/deep-links.ts, and a custom folder only has its +// JMAP id. The connector carries the alias list purely as suggestions for its +// link generator. + +export type ParamType = 'enum' | 'slug' | 'date' | 'id' | 'path'; + +export interface ParamSpec { + readonly type: ParamType; + readonly values?: readonly string[]; + readonly required?: boolean; +} + +/** + * What a resolved target asks the client to do. + * + * Most targets are just a path. The admin ones carry a `tab` as well, because + * the admin shell keeps its active tab in a zustand store rather than in the + * URL (see app/(main)/admin/layout.tsx) - so reaching a specific panel means + * setting that store before navigating to /admin. If admin ever gains real + * `/admin/` routes, this collapses back into a plain path. + */ +export interface Resolution { + /** App-relative path; feed through `appPath()` before navigating. */ + readonly path: string; + readonly adminTab?: AdminTabId; + /** True when the destination requires an admin session, not a user one. */ + readonly admin?: boolean; +} + +export interface Target { + readonly name: string; + /** Shown on the "this version doesn't know that link" card. */ + readonly label: string; + readonly params?: Readonly>; + readonly resolve: (params: Readonly>) => Resolution; +} + +export const TARGETS: readonly Target[] = [ + { + name: 'app', + label: 'Bulwark', + resolve: () => ({ path: '/mail' }), + }, + { + name: 'settings', + label: 'Settings', + params: { tab: { type: 'enum', values: SETTINGS_TABS } }, + resolve: (p) => ({ path: buildSettingsPath(p.tab ?? null) }), + }, + { + name: 'mail_folder', + label: 'a mail folder', + params: { ref: { type: 'id' } }, + resolve: (p) => ({ + path: p.ref ? `/mail/folder/${encodeURIComponent(p.ref)}` : '/mail', + }), + }, + { + name: 'calendar', + label: 'Calendar', + params: { + view: { type: 'enum', values: CALENDAR_VIEWS }, + date: { type: 'date' }, + }, + resolve: (p) => { + const view = p.view ?? 'month'; + return { path: p.date ? `/calendar/${view}/${p.date}` : `/calendar/${view}` }; + }, + }, + { + name: 'contacts', + label: 'Contacts', + resolve: () => ({ path: '/contacts' }), + }, + { + name: 'files', + label: 'Files', + params: { path: { type: 'path' } }, + resolve: (p) => ({ path: buildFilesPath(p.path ?? '') }), + }, + { + name: 'admin', + label: 'the admin area', + params: { tab: { type: 'enum', values: ADMIN_TABS } }, + resolve: (p) => ({ + path: '/admin', + adminTab: (p.tab as AdminTabId | undefined) ?? 'dashboard', + admin: true, + }), + }, + { + name: 'admin_extension', + label: 'an extension in the marketplace', + params: { slug: { type: 'slug', required: true } }, + resolve: (p) => ({ + path: `/admin/marketplace/${encodeURIComponent(p.slug)}`, + admin: true, + }), + }, + { + name: 'admin_plugin', + label: "a plugin's settings", + params: { id: { type: 'slug', required: true } }, + resolve: (p) => ({ path: `/admin/plugins/${encodeURIComponent(p.id)}`, admin: true }), + }, + { + name: 'admin_auth', + label: 'authentication settings', + resolve: () => ({ path: '/admin', adminTab: 'auth', admin: true }), + }, + { + name: 'setup', + label: 'the setup wizard', + resolve: () => ({ path: '/setup' }), + }, +] as const; + +const BY_NAME = new Map(TARGETS.map((t) => [t.name, t])); + +/** Names this instance can resolve - what `/api/connector/capabilities` reports. */ +export const TARGET_NAMES: readonly string[] = TARGETS.map((t) => t.name); + +export function getTarget(name: string | null | undefined): Target | null { + if (!name) return null; + return BY_NAME.get(name) ?? null; +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +const MAX_PARAM_LENGTH = 512; +const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/; +const DATE = /^\d{4}-\d{2}-\d{2}$/; + +function isRealDate(value: string): boolean { + if (!DATE.test(value)) return false; + const [y, m, d] = value.split('-').map(Number); + const date = new Date(y, m - 1, d); + return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d; +} + +function checkParam(spec: ParamSpec, value: string): boolean { + if (value.length > MAX_PARAM_LENGTH) return false; + switch (spec.type) { + case 'enum': + return (spec.values ?? []).includes(value); + case 'slug': + return SLUG.test(value); + case 'date': + return isRealDate(value); + case 'id': + // Becomes one path segment, so it must not be able to climb out of it. + // `%` is refused along with the separators because the value is decoded + // twice on the way in - once by the router, once by `resolveFolderRef` + // in deep-links.ts - so `..%2Fadmin` would arrive as `../admin`. Real + // JMAP ids have no percent signs in them. + return !/[/\\?#%]/.test(value) && value !== '.' && value !== '..'; + case 'path': { + const segments = value.split('/').filter(Boolean); + if (segments.length === 0 || segments.length > 32) return false; + return !segments.some((s) => s === '.' || s === '..' || /[\\?#]/.test(s)); + } + } +} + +export type ResolveResult = + | { readonly kind: 'ok'; readonly target: Target; readonly resolution: Resolution } + /** The registry has no such name - an instance older than the link. */ + | { readonly kind: 'unknown-target' } + /** A declared parameter is malformed. Never echoed back into the page. */ + | { readonly kind: 'bad-param'; readonly target: Target; readonly param: string }; + +/** + * Turns `/connector/?` into a path inside this app. + * + * Never throws and never 404s: an unparseable link is a card the user can read, + * not a dead end. Unknown parameters are dropped rather than refused, so a + * newer connector can hand an older instance a link carrying something extra + * and have it still open the right surface. + */ +export function resolveTarget( + name: string | null | undefined, + search: URLSearchParams | Readonly>, +): ResolveResult { + const target = getTarget(name); + if (!target) return { kind: 'unknown-target' }; + + const read = (key: string): string | null => + search instanceof URLSearchParams ? search.get(key) : (search[key] ?? null); + + const params: Record = {}; + for (const [key, spec] of Object.entries(target.params ?? {})) { + const value = read(key); + if (value === null || value === '') { + if (spec.required) return { kind: 'bad-param', target, param: key }; + continue; + } + if (!checkParam(spec, value)) return { kind: 'bad-param', target, param: key }; + params[key] = value; + } + + return { kind: 'ok', target, resolution: target.resolve(params) }; +} diff --git a/proxy.ts b/proxy.ts index ede029dd1..88cc67601 100644 --- a/proxy.ts +++ b/proxy.ts @@ -77,7 +77,7 @@ export function isStaticAssetPath(pathname: string): boolean { * Next-Url, Cookie, Accept-Language, ... from every page request that skips * the intl middleware: all locale-prefixed paths - i.e. every page of a * NEXT_PUBLIC_LOCALE_PREFIX=always (Docker) build - plus /admin, /protocol, - * /setup and the plugin sandbox. Since Next 16.3 the server recomputes the + * /setup, /connector and the plugin sandbox. Since Next 16.3 the server recomputes the * `_rsc` cache-busting hash from those router headers * (experimental.validateRSCRequestHeaders, on by default) and answers a * mismatch with a 307 to the "expected" URL; the client re-requests, the @@ -291,6 +291,10 @@ export async function proxy(request: NextRequest) { const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/'); const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/'); const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/'); + // Connector links (/connector/) are published in docs and READMEs + // and must not carry a locale. Letting next-intl rewrite them to + // /en/connector/... 404s, which breaks every link already in the wild. + const isConnectorRoute = pathname === '/connector' || pathname.startsWith('/connector/'); // The plugin sandbox lives in its own root layout under app/(sandbox)/ and // is not part of the localized tree. Letting next-intl rewrite the path to // /en/plugin-sandbox 404s, which kills the iframe and disables every plugin. @@ -305,7 +309,14 @@ export async function proxy(request: NextRequest) { ); let intlResponse: ReturnType | null = null; - if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !isSandboxRoute && !hasLocalePrefix) { + if ( + !isAdminRoute && + !isProtocolRoute && + !isSetupRoute && + !isSandboxRoute && + !isConnectorRoute && + !hasLocalePrefix + ) { try { intlResponse = intlMiddleware(withMatchedChineseAcceptLanguage(request)); } catch (error) { diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 0a8ce89b6..965ebd91f 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -155,6 +155,41 @@ export function buildLiteConfig(env = {}, { target = "static" } = {}) { } /** policy.json: only the gates Lite must pin; the app merges the rest over its defaults. */ +/** + * What `/api/connector/capabilities` answers on a server build, as a file. + * + * Lite is a static export with no route handlers, so a connector probing this + * instance gets this instead - same document, same meaning. The connector + * tries the API path first and falls back to `/connector.json`. + * + * The target names are read out of lib/connector/registry.ts rather than + * duplicated here: a list that drifts would have this build telling connectors + * it can resolve links it cannot. + */ +export function buildConnectorCapabilities({ appName, version, basePath = "" }) { + const source = readFileSync( + join(resolveRepoRoot(import.meta.url), "lib", "connector", "registry.ts"), + "utf8", + ); + const targets = [...source.matchAll(/^\s{4}name: '([a-z0-9_]+)',$/gm)].map((m) => m[1]); + if (targets.length === 0) { + throw new Error("no connector targets found in lib/connector/registry.ts"); + } + return ( + JSON.stringify( + { + product: "bulwark-webmail", + appName: appName || "Bulwark", + version: version || null, + connectorPath: `${basePath || ""}/connector`, + targets, + }, + null, + 2, + ) + "\n" + ); +} + export function buildLitePolicy() { return { _comment: "Optional admin policy for Bulwark Lite (same shape as the admin dashboard's policy). Plugins and sidebar apps stay off in Lite.", @@ -331,6 +366,9 @@ export function buildHeaders({ basePath = "", connectSrc = "*" }) { Content-Security-Policy: ${csp} ${basePath || ""}/_next/static/* Cache-Control: public, max-age=31536000, immutable +${basePath || ""}/connector.json + Access-Control-Allow-Origin: * + Cache-Control: public, max-age=300 `; } diff --git a/scripts/lite/postbuild.mjs b/scripts/lite/postbuild.mjs index b4a50fbb4..d67da0ed3 100644 --- a/scripts/lite/postbuild.mjs +++ b/scripts/lite/postbuild.mjs @@ -11,7 +11,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { join } from "node:path"; import { STALWART_EXCLUDED_DIRS, STALWART_EXCLUDED_FILES, STALWART_ZIP_NAME, - buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect, + buildCaddyExample, buildConnectorCapabilities, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect, buildStalwartEntry, buildStalwartManifest, buildStalwartReadme, buildZip, collectZipEntries, discoverBuiltLocales, discoverShellDirs, findSegmentPrefetchDirs, isMainModule, makeBuildId, normalizeBasePath, parseLiteTarget, resolveRepoRoot, } from "./lib.mjs"; @@ -64,6 +64,9 @@ function staticFiles({ config, basePath, locales, defaultLocale, version, commit return { "config.json": JSON.stringify(config, null, 2) + "\n", "policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n", + // The file form of /api/connector/capabilities - a static export has no + // route handlers, and a connector probing this instance needs an answer. + "connector.json": buildConnectorCapabilities({ appName: config.appName, version, basePath }), "manifest.webmanifest": JSON.stringify(buildManifest({ appName: config.appName, basePath }), null, 2) + "\n", "index.html": buildRootRedirect({ basePath, locales, defaultLocale }), // Replaces Next's default not-found page: only this shim replays deep links diff --git a/scripts/lite/verify.mjs b/scripts/lite/verify.mjs index 9960a50ee..603b62826 100644 --- a/scripts/lite/verify.mjs +++ b/scripts/lite/verify.mjs @@ -45,7 +45,10 @@ export function verifyExport({ root = repoRoot, target, safeBundleBytes = STALWA const required = resolvedTarget === "stalwart" ? ["index.html", "config.json", "policy.json", "manifest.json", "LITE-README.md", "lite-build.json"] - : ["index.html", "404.html", "config.json", "policy.json", "manifest.webmanifest", "_redirects", "_headers", "LITE-README.md", "lite-build.json"]; + // connector.json is the static target only: the Stalwart bundle is served + // without custom headers, so a connector's cross-origin probe could not + // read it anyway (it falls back to adding the instance unverified). + : ["index.html", "404.html", "config.json", "policy.json", "connector.json", "manifest.webmanifest", "_redirects", "_headers", "LITE-README.md", "lite-build.json"]; for (const file of required) { if (!existsSync(join(outDir, file))) problems.push(`missing out/${file}`); }