feat: let an install link name the extension version to offer in the marketplace

This commit is contained in:
Linus Rath
2026-10-19 00:07:04 +02:00
parent 41a52ec53b
commit dc837c25f2
5 changed files with 68 additions and 10 deletions
+5 -2
View File
@@ -44,9 +44,12 @@ export default function AdminLoginPage() {
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');
const search = new URLSearchParams(query ?? '');
const tab = search.get('tab');
if (tab && isAdminTab(tab)) useAdminTabStore.getState().setActiveTab(tab);
router.push(path);
// The tab rides in the store; a version pick (an install link to one
// version of an extension) stays in the URL for the marketplace page.
router.push(search.has('version') ? next : path);
return;
}
router.push('/admin');
+32
View File
@@ -82,6 +82,24 @@ describe('resolving to app paths', () => {
}
});
it('carries the version an install link asked for into the marketplace page', () => {
expect(path('admin_extension', { slug: 'quick-reply', version: '1.2.0' })).toBe(
'/admin/marketplace/quick-reply?version=1.2.0',
);
expect(path('admin_extension', { slug: 'quick-reply', version: '2.0.0-beta.1' })).toBe(
'/admin/marketplace/quick-reply?version=2.0.0-beta.1',
);
});
it('survives the admin login round-trip as a whole', () => {
// The admin layout parks the resolved path in ?next=; the gate there must
// let exactly what the registry produces through, or a versioned install
// link would drop its version (or the whole path) on the way.
const versioned = path('admin_extension', { slug: 'quick-reply', version: '1.2.0' });
expect(safeAdminNext(versioned)).toBe(versioned);
expect(safeAdminNext(encodeURIComponent(versioned))).toBe(versioned);
});
it('defaults the admin tab when the link named none', () => {
const admin = resolve('admin');
if (admin.kind === 'ok') expect(admin.resolution.adminTab).toBe('dashboard');
@@ -125,6 +143,15 @@ describe('parameter validation', () => {
},
);
it.each(['1.2', 'v1.2.3', '1.2.3+build.5', 'latest', '1.2.3/..', '1.2.3?x', '1.2.3-'])(
'refuses the extension version %j',
(version) => {
const result = resolve('admin_extension', { slug: 'quick-reply', version });
expect(result.kind).toBe('bad-param');
if (result.kind === 'bad-param') expect(result.param).toBe('version');
},
);
it.each(['2026-02-31', '2026-13-01', '20260920', 'tomorrow'])(
'refuses the date %j',
(date) => {
@@ -167,6 +194,8 @@ describe('safeAdminNext', () => {
['/admin/', '/admin/'],
['/admin/marketplace/quick-reply', '/admin/marketplace/quick-reply'],
['/admin?tab=policy', '/admin?tab=policy'],
['/admin/marketplace/quick-reply?version=1.2.0', '/admin/marketplace/quick-reply?version=1.2.0'],
['/admin/marketplace/quick-reply?version=2.0.0-beta.1', '/admin/marketplace/quick-reply?version=2.0.0-beta.1'],
['%2Fadmin%2Fplugins%2Fx', '/admin/plugins/x'],
])('accepts %j', (raw, expected) => {
expect(safeAdminNext(raw)).toBe(expected);
@@ -185,6 +214,9 @@ describe('safeAdminNext', () => {
'/admin/../mail',
'/adminfoo',
'/admin?tab=policy&x=1',
'/admin/marketplace/x?version=1.2.0&tab=policy',
'/admin/marketplace/x?version=../../etc',
'/admin/marketplace/x?version=' + '1'.repeat(70),
'/admin?next=https://evil.example',
'%2f%2fevil.example',
'/admin/' + 'a'.repeat(600),
+9 -5
View File
@@ -20,9 +20,11 @@ const SAFE = /^\/admin(?:\/[A-Za-z0-9._~-]+)*\/?$/;
* 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.
* Two queries are allowed. `?tab=` is 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. `?version=` is the version a connector
* install link asked for (`/admin/marketplace/<slug>?version=1.2.0`); the
* marketplace page checks it again against the directory before offering it.
*/
export function safeAdminNext(raw: string | null | undefined): string | null {
if (!raw) return null;
@@ -44,8 +46,10 @@ export function safeAdminNext(raw: string | null | undefined): string | 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;
// Only the tab hand-off or the version pick, each as one plain value.
if (query && !/^tab=[a-z_]{1,32}$/.test(query) && !/^version=[0-9A-Za-z.-]{1,64}$/.test(query)) {
return null;
}
return query ? `${path}?${query}` : path;
}
+12 -3
View File
@@ -30,6 +30,7 @@
import { ADMIN_TABS, type AdminTabId } from '@/stores/admin-tab-store';
import type { SettingsSearchTab } from '@/lib/settings-search';
import { buildSettingsPath, buildFilesPath } from '@/lib/deep-links';
import { RELEASE_VERSION } from '@/lib/release-version';
/** Mirrors `SettingsSearchTab`. Listed so an unknown tab is refused here. */
const SETTINGS_TABS: readonly SettingsSearchTab[] = [
@@ -48,7 +49,7 @@ const CALENDAR_VIEWS = ['month', 'week', 'day', 'agenda', 'tasks'] as const;
// JMAP id. The connector carries the alias list purely as suggestions for its
// link generator.
export type ParamType = 'enum' | 'slug' | 'date' | 'id' | 'path';
export type ParamType = 'enum' | 'slug' | 'date' | 'id' | 'path' | 'version';
export interface ParamSpec {
readonly type: ParamType;
@@ -137,9 +138,15 @@ export const TARGETS: readonly Target[] = [
{
name: 'admin_extension',
label: 'an extension in the marketplace',
params: { slug: { type: 'slug', required: true } },
// `version` picks the version the page offers to install; without it the
// page offers the newest one this Bulwark can run. The marketplace page
// reads it from the query string, and `safeAdminNext` lets exactly this
// query through the admin login.
params: { slug: { type: 'slug', required: true }, version: { type: 'version' } },
resolve: (p) => ({
path: `/admin/marketplace/${encodeURIComponent(p.slug)}`,
path: p.version
? `/admin/marketplace/${encodeURIComponent(p.slug)}?version=${encodeURIComponent(p.version)}`
: `/admin/marketplace/${encodeURIComponent(p.slug)}`,
admin: true,
}),
},
@@ -195,6 +202,8 @@ function checkParam(spec: ParamSpec, value: string): boolean {
return SLUG.test(value);
case 'date':
return isRealDate(value);
case 'version':
return RELEASE_VERSION.test(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
+10
View File
@@ -0,0 +1,10 @@
/**
* The shape of a version the extension directory publishes: semver core with
* an optional pre-release (`1.2.3`, `2.0.0-beta.1`). Build metadata is not
* part of a published version and is refused with everything else.
*
* Shared by the connector registry (the `version` link parameter), the admin
* login's `?next=` gate and the marketplace routes, so a version that passes
* one passes all of them.
*/
export const RELEASE_VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;