feat: Add customization URL parameter (#5992)

* Add customization URL parameter

* fix: Preserve should be customizeable

* Update customizations docs

* fix: Overlay items on patient name

* Add customization test

* Fix resolve to absolute path

* fix: Warn on no data in load

* Remove unused customization stuff

* fix: PR comments

* Update stored parameters to only use an array for mulitples

* Remove requires ohif.* special call out

* Remove strict mode

* PR comments

* Document segmentation examples

* Add three examples as requested

* PR comments

* lock

* Remove old customizatoin export

* fix: Ordering issues on customization loads

* fix: Use correct default for dev builds app config

* Fixes for conflicts

* chore: restore pnpm-lock.yaml to match master

The lockfile diff was incidental peer-descriptor churn and carried no
functional dependency change. It tripped the CircleCI security-audit gate
(which only runs when pnpm-lock.yaml is in the PR diff), surfacing a
pre-existing critical `decompress` transitive vuln that also exists on
master. Restoring master's lockfile removes the audit trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): restore json5 lockfile entry; ignore unfixable decompress GHSA

The previous commit restored pnpm-lock.yaml from master, which dropped the
json5@2.2.3 entry that platform/core legitimately depends on (JSONC parsing
for the customization feature). That broke `--frozen-lockfile` install
(ERR_PNPM_OUTDATED_LOCKFILE). This restores the correct lockfile.

Because the lockfile must change (json5), the CircleCI security-audit gate
runs and previously failed on a critical `decompress` <=4.2.1 zip-slip
advisory. This is a pre-existing transitive vuln (present on master too) with
no published patch — decompress's latest release is 4.2.1, so no version
bump/override can resolve it. It reaches the tree only via @itk-wasm/dam, a
build/data-asset extraction tool under @cornerstonejs/labelmap-interpolation.

Add GHSA-mp2f-45pm-3cg9 to the existing pnpm-workspace.yaml auditConfig
ignoreGhsas accepted-risk list, matching how the repo already exempts other
build-tooling advisories. `pnpm audit --audit-level high` now passes locally
(1 critical ignored, 0 high).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): fix visitStudy URL encoding that broke mpr2 study load

The visitStudy rewrite (added for the ?customization= option) built the URL
with new URLSearchParams({ StudyInstanceUIDs: studyInstanceUID }), which
percent-encodes the value. mpr2.spec.ts embeds an extra param in the UID
string ('<uid>&hangingprotocolid=mpr'), so the & and = were encoded and the
whole thing collapsed into one invalid StudyInstanceUIDs value -> the study
could not be found ('studies are not available'), the viewer never rendered,
and the side-panel-header-right click timed out.

Restore master's raw concatenation for StudyInstanceUIDs (so embedded params
survive as separate query params) while still appending the customization
option separately. Only mpr2 embeds & in the UID, matching the single failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR comments

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bill WallaceandClaude Opus 4.8 authored and GitHub committed 2026-07-07 15:30:27 -04:00
1 parent 3d9a17bc0c
commit 3dd5c70cb2
58 files changed
+3411 -586

No files matched your search

@@ -0,0 +1,203 @@
---
sidebar_position: 7
sidebar_label: Customization URL & config lockdown
title: '?customization= URL parameter and the secure default config'
---
# `?customization=` URL parameter and the secure default config
3.13 adds the ability to load customizations at runtime from the
`?customization=` URL parameter, and as part of that it **locks down the default
configuration**. Both changes can affect existing deployments.
## `?customization=` is off by default
A `?customization=` value loads a **JSONC data file** (JSON with comments /
trailing commas) and applies its `global` payload as global customizations. The
file is fetched and parsed as **data — it is never executed**. Executable code
(plugins, modes, extensions) still loads only through `pluginConfig.json`.
The feature is **off until you configure an allowlist of prefixes** in the app
config. This is an **app-config property, not a customization** — a customization
could itself be loaded from the URL, so it must not be able to widen its own
allowlist.
```js
window.config = {
customizationUrlPrefixes: {
// The `default` prefix (no slashes) handles values with no leading slash.
default: './customizations/', // ?customization=ctPresets -> ./customizations/ctPresets.jsonc
// Every other prefix MUST start and end with a slash and is matched against
// the leading `/segment/` of the value.
'/remote/': 'https://cdn.example.com/ohif-customizations/', // ?customization=/remote/siteA
},
};
```
Resolution rules:
- `?customization=ctPresets` → `default` prefix → `./customizations/ctPresets.jsonc`
- `?customization=/remote/siteA` → `/remote/` prefix → `https://cdn.example.com/ohif-customizations/siteA.jsonc`
- **A value whose prefix is not configured throws and aborts startup** rather than
being silently ignored. With no `customizationUrlPrefixes` configured, *any*
`?customization=` value throws. (Note: because the `default` prefix has no
slashes, an explicit `/default/x` is treated as the `/default/` prefix and is
rejected unless you configure that key — use the bare `x` form instead.)
If you previously enabled this feature via the `ohif.customizationUrl`
**customization** (an early iteration of this PR), move the `prefixes` map to the
top-level `customizationUrlPrefixes` **app-config** property and drop the
`prefixes` wrapper key:
```js
// Before (customization — no longer read)
customizationService: [{ 'ohif.customizationUrl': { $set: { prefixes: { default: './customizations/' } } } }],
// After (app config property)
customizationUrlPrefixes: { default: './customizations/' },
```
## Phase-tagged customizations (lifecycle ordering)
A customization module — whether loaded from a `?customization=` data file or
declared inline in `appConfig.customizationService` — can tag its payload with
the lifecycle phase it should be applied in. This makes ordering deterministic
regardless of when extensions and modes load, and lets a single source target
the bootstrap, global, and mode-entry time frames at once.
```jsonc
{
// Other customization data files to resolve FIRST (depth-first). Each entry is
// itself a `?customization=` value — a customization module name resolved
// through the same `customizationUrlPrefixes` rules — NOT a customization id.
// So `"requiredCustomizationToLoad"` fetches
// ./customizations/requiredCustomizationToLoad.jsonc (via the `default` prefix)
// and applies its phase blocks before this file's.
"requires": ["requiredCustomizationToLoad"],
// Applied (Global scope) BEFORE extensions register — in place while they init.
"bootstrap": { "someId": { "$set": "value" } },
// Applied (Global scope) AFTER extensions register / init — so `$apply`-style
// merges can build on extension-provided defaults.
"global": { "workList.columns": { "$splice": [[1, 0, { "id": "patientBirthDate", "meta": { "label": "Birth Date" } }]] } },
// Applied (Mode scope) on EVERY mode enter. The reserved `*` (general) block
// is applied first; a block keyed by the entered mode's id / routeName is
// applied after it, so a single mode can override the general values.
"mode": {
"*": { "someId": { "$set": "all modes" } },
"viewer": { "someId": { "$set": "viewer only" } }
}
}
```
The same shape is accepted by the app config. The legacy array / object form is
still supported and is applied to the Global scope during `init()` exactly as
before:
```js
window.config = {
// `customizationUrlPrefixes` is a top-level app-config (window.config) property,
// deliberately NOT part of `customizationService`. The `customizationService`
// block below — and any customization loaded from a `?customization=` URL — can
// never read or widen this allowlist, because a URL-loaded customization must
// not be able to grant itself new load locations. It can only be changed here,
// in the global config, which is not itself updatable by any customization.
customizationUrlPrefixes: { default: './customizations/' },
customizationService: {
requires: ['patientBirthDate'], // resolves ./customizations/patientBirthDate.jsonc
global: [ // mixes string references and inline maps
'@ohif/extension-default.customizationModule.datasources',
{ 'workList.variant': 'default' },
],
mode: {
'*': { 'someId': { $set: 'all modes' } },
viewer: { 'someId': { $set: 'viewer only' } },
},
},
};
```
How the phases map onto the boot sequence:
| Phase | When | Scope |
| --- | --- | --- |
| `requires` | resolved up front (before extensions register) | — (loads other modules) |
| `bootstrap` | before `extensionManager.registerExtensions` | Global |
| `global` | after extensions register + `customizationService.init` | Global |
| `mode` | on each mode enter, after the mode scope is reset | Mode |
All `?customization=` data files and `requires` are **fetched once, up front**
(well before any mode loads); only the *application* of each phase block is
deferred to its lifecycle point.
> Note: a `?customization=` data file is JSON and cannot carry render functions.
> The WorkList expands a serializable column spec (an entry with an `id` and
> `meta` but no `accessorFn`/`cell`) into a display-only text column that reads
> `row[id]`, which is how the `patientBirthDate` example above renders.
## `config/default.js` is now a secure, minimal baseline
`config/default.js` — what a plain production build with no `APP_CONFIG` emits —
is now deliberately locked down. The most impactful part of this is the **data
source list**, which previously shipped seven sources and now ships **one**.
### Data sources removed from `config/default.js`
| `sourceName` | Type | Why it was removed |
| --- | --- | --- |
| `ohif2` | DICOMweb (AWS CloudFront `dd14fa38…`) | Extra read-only demo server; not needed in a baseline. |
| `ohif3` | DICOMweb (AWS CloudFront `d3t6nz73…`) | Extra read-only demo server; not needed in a baseline. |
| `local5000` | DICOMweb (`http://localhost:5000`) | Points at a local dev PACS that does not exist in a real deployment. |
| `orthanc` | DICOMweb (`http://localhost/pacs/dicom-web`) | Points at a local dev PACS that does not exist in a real deployment. |
| `dicomjson` | Runtime `?url=` JSON metadata | Loads metadata from an arbitrary `?url=`; widens the attack surface of a default build. Gate with `dangerouslyAllowedOriginsForAuthenticatedEnvironments` if you re-enable it. |
| `dicomwebproxy` | Runtime `?url=` delegating proxy | Driven by `?url=`; same attack-surface concern as `dicomjson`. |
| `dicomlocal` | Local file (drag-and-drop) | Loads DICOM files from the user's machine; not appropriate for a locked-down baseline. |
### What remains
- A single **read-only demo DICOMweb source** (`sourceName: 'ohif'`,
`defaultDataSourceName: 'ohif'`) — replace its `wadoRoot` / `qidoRoot` /
`wadoUriRoot` with your own DICOMweb server.
### Other lockdowns in `config/default.js`
- The `multimonitor` layouts were removed.
- `?customization=` is off (no `customizationUrlPrefixes`).
- `dangerouslyUseDynamicConfig` (the `configUrl` query parameter) is off.
### If you relied on the old `default.js`
If your deployment relied on `default.js` shipping the local/`?url=` data sources
or the full demo source list, you have two options:
- **Point the dev/demo configs at it**: copy the `dataSources` entries you need
from `config/dev.js` or `config/netlify.js` into your own config, or
- **Build with a different config**: set `APP_CONFIG` to your own config file at
build time (`APP_CONFIG=config/my-site.js pnpm run build`).
## New `config/dev.js`; dev server no longer uses `default.js`
- **`config/dev.js`** (new) is the full-featured local-development config: every
data source enabled and `?customization=` turned on. The dev-server scripts
(`pnpm run dev`, `dev:fast`, `start`) now default to `config/dev.js`.
- **`config/netlify.js`** is the public demo / Netlify deploy config: the full
data source set plus `customizationUrlPrefixes: { default: './customizations/' }`.
- **`config/default.js`** remains the fallback for a real production build
(`pnpm run build` with no `APP_CONFIG`).
### `APP_CONFIG` is honored, not clobbered
The default config is selected by the build (`webpack.pwa.js` / `rsbuild.config.ts`),
**not** hard-coded into the npm scripts, so an explicit `APP_CONFIG` always wins:
- `pnpm run dev` (no `APP_CONFIG`) → `config/dev.js`
- `APP_CONFIG=config/local_orthanc.js pnpm run dev` → **`config/local_orthanc.js`**
- `pnpm run build` (no `APP_CONFIG`) → `config/default.js`
- `APP_CONFIG=config/my-site.js pnpm run build` → **`config/my-site.js`**
The rule is: **the dev server defaults to `config/dev.js` and a production build
to `config/default.js`, but any explicit `APP_CONFIG` overrides the default.** So
if you maintain your own dev tooling that expected `pnpm run dev` to use
`config/default.js`, just set `APP_CONFIG=config/default.js` explicitly.
@@ -236,6 +236,20 @@ When a customization is retrieved:
As you have guessed the `.setCustomizations` accept a second argument which is the scope. By default it is set to `mode`.
## Areas of Customization
Use this introduction page for the core model (scope, priority, and syntax), then refer to focused pages for specific areas:
- [Specific Customizations](./specificCustomizations.md): Built-in keys such as `ohif.preserveCustomizationKeys`, plus the `customizationUrlPrefixes` app-config allowlist that drives `?customization=` (off by default) and `requires` behavior, and how URL-loaded files interact with bootstrap and SPA navigation (intended one-time load per page).
- [Custom Routes](./customRoutes.md): Route-level customization through `routes.customRoutes`.
- [Context Menu](./contextMenu.md): Context menu structures and interaction customization.
- [Study Browser](./StudyBrowser.md): Study browser-specific configuration values.
- [Viewport Overlay](./viewportOverlay.md): Overlay item configuration and layout.
- [Viewport Scrollbar](./ViewportScrollbar.md): Scrollbar behavior and display customizations.
- [Measurements](./Measurements.md): Measurement-related customization surface.
- [Segmentation](./Segmentation.md): Segmentation-specific customization options.
- [Advanced Customization](./advanced.md): `inheritsFrom`, `$transform`, and compositional patterns.
## Customization Syntax
@@ -0,0 +1,234 @@
---
title: Specific Customizations
summary: Documentation for specific built-in customization keys, including URL parameter preservation and URL-driven customization module loading.
sidebar_position: 9
---
# Specific Customizations
This page documents concrete customization keys that have app-level behavior.
## `ohif.preserveCustomizationKeys`
- **Purpose**: Controls which query-string keys should be preserved while navigating between worklist and viewer routes.
- **Default behavior**: The app always preserves:
- `configUrl`
- `multimonitor`
- `screenNumber`
- `hangingProtocolId`
- `customization`
- **How this customization is applied**: The value from `ohif.preserveCustomizationKeys` is appended to the default list above (it does not replace the defaults).
Example:
```js
window.config = {
customizationService: [
{
'ohif.preserveCustomizationKeys': {
$set: ['customizationAlt', 'experimentFlag'],
},
},
],
};
```
With this example, navigation preserves the default keys plus `customizationAlt` and `experimentFlag`.
## `customizationUrlPrefixes` (app config)
- **Purpose**: Allowlist of prefixes that `?customization=` values may resolve against. The `?customization=` feature is **off until this is configured**.
- **It is an app-config property, not a customization.** Because a customization can itself be loaded from the URL, letting one define prefixes would let it widen its own allowlist — so the allowlist lives on `window.config` directly and is never read from `customizationService`.
- **Prefix model**:
- The special `default` prefix (no slashes) is used for values **without** a leading slash; the whole value is the file name (e.g. `ctAbdomen`, or `siteA/theme`).
- Every other prefix **must start and end with a slash** (e.g. `/remote/`) and is matched against the leading `/segment/` of the value.
- **Off by default**: with no `customizationUrlPrefixes` configured, any `?customization=` value is rejected (see below).
Example:
```js
window.config = {
customizationUrlPrefixes: {
default: './customizations/',
'/remote/': 'https://cdn.example.com/ohif-customizations/',
},
};
```
### Using `?customization=`
You can pass one or more customization entries in the URL:
- `?customization=ctAbdomen` → `default` prefix → `./customizations/ctAbdomen.jsonc`
- `?customization=/remote/siteA` → `/remote/` prefix → `https://cdn.example.com/ohif-customizations/siteA.jsonc`
- `?customization=basePack&customization=siteOverrides`
Each entry is split into a prefix and a name, resolved through `customizationUrlPrefixes`, fetched, parsed as JSONC, and then applied.
#### Security considerations (`?customization=`)
A URL-loaded customization is a **JSONC data file** (JSON with comments / trailing commas). It is fetched and parsed as data — it is **never executed** as code. Executable code (plugins, modes, extensions) loads only through `pluginConfig.json`, never from the customization URL path. This makes `?customization=` far lower risk than loading a JavaScript bundle, but the values still change application behavior, so treat the source directories as trusted configuration.
- **Off until configured, and a hard failure when misused:** With no `customizationUrlPrefixes` set, every `?customization=` value is rejected. A value whose prefix is not on the allowlist **throws and aborts app startup** rather than being silently ignored — so a stray or hostile `?customization=` link on an unconfigured deployment fails loudly instead of partially applying.
- **Allowlisted resolution only:** The loader rejects values that look like full URLs (with a scheme), rejects path traversal (`..`), rejects unknown prefixes, and rejects unsafe name segments. The final fetch URL is always built from your configured `customizationUrlPrefixes` plus a `.jsonc` file under that base—users cannot pass an arbitrary absolute URL as the customization token alone.
- **Your prefixes define the trust boundary:** If a `prefix` maps to a host or path you do not control, or to a directory where untrusted parties can publish files, `?customization=` becomes a way to inject configuration into the app. Prefer HTTPS bases, narrow directories, and static hosting of reviewed files.
- **`requires` chains:** Dependencies declared in a loaded file are resolved with the **same** policy and validation. A trusted root file can still pull in further files from the same prefix allowlist—review entire chains you ship.
- **Links and social engineering:** Anyone can share a URL that includes `?customization=...`. Recipients’ browsers will attempt to load the corresponding modules if they pass validation. Combine with normal defenses (user education, authenticated portals, enterprise policies) as you would for any deep link that changes application behavior.
#### What `requires` means
A customization file can declare dependencies via `requires` so dependent files load first.
Example file shape:
```jsonc
{
// load these first, then apply this file's `global` payload
"requires": ["basePack", "/remote/sharedTools"],
"global": {
"someCustomizationKey": {
"$set": true
}
}
}
```
When this file is loaded via `?customization=...`, the loader:
1. Resolves and loads each `requires` dependency first.
2. Applies dependency customizations first.
3. Applies the requested module after dependencies.
This allows packaging layered customizations (base -> shared -> site-specific) without repeating setup in every module.
### Example modules
A URL-loaded file applies its `global` payload as **global customizations** — the same layer as
`window.config`'s `customizationService` entries, but loaded at runtime from `?customization=`. Any
customization key that is read through `customizationService.getCustomization(...)` can therefore be
set this way. The examples below are complete files; drop one under `platform/app/public/customizations/`
(the `default` prefix) and load it with `?customization=<fileName>`. Because the files are JSONC, you
can keep `//` comments and trailing commas in them.
The shipped [`veterinaryOverlay.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/veterinaryOverlay.jsonc)
demonstrates a fourth scenario — replacing the viewport overlay layout via `viewportOverlay.topLeft` /
`viewportOverlay.topRight`.
#### 1. Site-specific window/level presets
Override the CT presets offered in the window-level menu (key: `cornerstone.windowLevelPresets`).
`$merge` replaces only the `CT` entry, so presets for other modalities (PT, etc.) are kept.
```jsonc
// platform/app/public/customizations/ctPresets.jsonc -> ?customization=ctPresets
{
"global": {
"cornerstone.windowLevelPresets": {
"$merge": {
"CT": [
{ "id": "ct-soft-tissue", "description": "Soft tissue", "window": "400", "level": "40" },
{ "id": "ct-lung", "description": "Lung", "window": "1500", "level": "-600" },
{ "id": "ct-angio", "description": "Angio", "window": "600", "level": "300" },
{ "id": "ct-bone", "description": "Bone", "window": "2500", "level": "480" }
]
}
}
}
}
```
#### 2. Predefined measurement labels
Make the viewer prompt for a label from a fixed list whenever a measurement is created
(key: `measurementLabels`).
```jsonc
// platform/app/public/customizations/measurementLabels.jsonc -> ?customization=measurementLabels
{
"global": {
"measurementLabels": {
"$set": {
"labelOnMeasure": true,
"exclusive": true,
"items": [
{ "value": "Head", "label": "Head" },
{ "value": "Shoulder", "label": "Shoulder" },
{ "value": "Knee", "label": "Knee" },
{ "value": "Toe", "label": "Toe" }
]
}
}
}
}
```
#### 3. Add a toolbar button
The basic and longitudinal viewers register their toolbar as customizations
(`cornerstone.toolbarButtons` — the button definitions, and `cornerstone.toolbarSections` — the
layout that maps each section to a list of button ids). A module can therefore add a button by
`$push`-ing a definition onto `cornerstone.toolbarButtons` and the button's id onto a section.
The shipped [`smoothRotate.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/smoothRotate.jsonc)
adds a **Smooth Rotate** button to the *More Tools* menu that activates the cornerstone `PlanarRotate`
tool (drag to rotate the image freely, unlike the fixed 90° *Rotate Right*):
```jsonc
// platform/app/public/customizations/smoothRotate.jsonc -> ?customization=smoothRotate
{
"global": {
"cornerstone.toolbarButtons": {
"$push": [
{
"id": "SmoothRotate",
"uiType": "ohif.toolButton",
"props": {
"type": "tool",
"icon": "tool-rotate-right",
"label": "Smooth Rotate",
"tooltip": "Smooth Rotate (drag to rotate the image freely)",
"commands": {
"commandName": "setToolActiveToolbar",
"commandOptions": { "toolName": "PlanarRotate" }
},
"evaluate": "evaluate.cornerstoneTool"
}
}
]
},
"cornerstone.toolbarSections": {
"MoreTools": { "$push": ["SmoothRotate"] }
}
}
}
```
> Because the cornerstone extension registers the default toolbar at the *default* scope and a URL
> module applies at the *global* scope, the `$push` **extends** the built-in buttons rather than
> replacing them. The same pattern works for any tool already in the active tool group.
Each payload value uses [immutability-helper](https://github.com/kolodny/immutability-helper)
commands (`$set`, `$push`, `$merge`, ...) exactly like `window.config` customizations, so a module can
also append to a list or merge into an existing object rather than replacing it wholesale.
### URL modules, bootstrap, and client-side navigation (intended behavior)
Modules referenced from `?customization=` are loaded when the app applies URL customizations from
`window.location.search`, which happens **once at bootstrap** in the default shell (for example
from app initialization). That is intentional:
- **No automatic refresh on SPA navigation:** Client-side routing may change the visible URL, and
keys such as `customization` are often **kept in the query string** on purpose (see
`ohif.preserveCustomizationKeys` above) so bookmarks and deep links stay consistent. That
preservation does **not** mean the viewer re-fetches URL customization files on every route
change.
- **Previously loaded files stay applied:** The service remembers each normalized key for
the lifetime of the page. A later call to the same loader path skips files that were already
fetched, and global payloads from those files are not rolled back when only the query string
changes.
If you need a different `?customization=` pack to take effect without a full reload, your
integration must trigger loading explicitly (for example by calling
`customizationService.applyCustomizationUrlSearchParams` or `customizationService.requires` with
the new list). New module keys not seen before can still be loaded that way; unloading or
replacing an already-loaded pack is not supported out of the box.