feat: resolve deep links to the local instance

This commit is contained in:
Linus Rath
2026-09-20 19:59:37 +02:00
parent c6bb4aace9
commit 77ef60caae
13 changed files with 797 additions and 5 deletions
+223
View File
@@ -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<string, string> = {}) =>
resolveTarget(name, search);
const path = (name: string, search: Record<string, string> = {}) => {
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);
},
);
});
+9
View File
@@ -265,6 +265,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
// Connector links (connector.bulwarkmail.org): docs and the extension
// directory link to a destination, and the reader's own browser resolves it
// to their instance. `connectorEnabled` covers both halves - the
// /api/connector/capabilities endpoint a connector probes, and the "add this
// instance" links the app offers - so a deployment that wants no third-party
// hostname anywhere can switch the whole thing off. `connectorUrl` points a
// white-label or self-hosted deployment at its own connector.
connectorEnabled: { envVar: 'CONNECTOR_ENABLED', type: 'boolean', defaultValue: true },
connectorUrl: { envVar: 'CONNECTOR_URL', type: 'url', defaultValue: 'https://connector.bulwarkmail.org' },
// WOPI document editing (#425). `wopiClientUrl` is the editor's base URL
// (Collabora Online / OnlyOffice / EuroOffice, ...); discovery is fetched
// from `<url>/hosting/discovery` unless the URL already carries a path.
+51
View File
@@ -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;
}
+250
View File
@@ -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 `<instance>/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/<tab>` 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<Record<string, ParamSpec>>;
readonly resolve: (params: Readonly<Record<string, string>>) => 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/<name>?<params>` 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<Record<string, string>>,
): 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<string, string> = {};
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) };
}