Skip to content

feat: Add customization URL parameter#5992

Open
wayfarer3130 wants to merge 13 commits into
masterfrom
feat/customization-url-parameter
Open

feat: Add customization URL parameter#5992
wayfarer3130 wants to merge 13 commits into
masterfrom
feat/customization-url-parameter

Conversation

@wayfarer3130
Copy link
Copy Markdown
Contributor

@wayfarer3130 wayfarer3130 commented May 4, 2026

Context

In order to allow custom versions of OHIF to be defined/added without having to rebuild OHIF, it is necessary to have a customization framework that can load dynamic modules. This has been added as a customization= parameter.

Changes & Results

Added a customization handler for the customization= parameter
Added a requires= export in the loaded global customizations to allow customizations to depend on other ones, eg veterinary depends on veterinaryOverlay
Add an example customization to test with, the start of a veterinary example.

Testing

Open the horse example with customization=veterinary in the URL
You should see additional overlays added, without having to rebuild.

Checklist

PR

  • [] My Pull Request title is descriptive, accurate and follows the
    semantic-release format and guidelines.

Code

  • [] My code has been well-documented (function documentation, inline comments,
    etc.)

Public Documentation Updates

  • [] The documentation page has been updated as necessary for any public API
    additions or removals.

Tested Environment

  • [] OS:
  • [] Node version:
  • [] Browser:

Greptile Summary

This PR adds a ?customization= URL parameter that lets operators load runtime JavaScript customization modules (global overlay overrides, etc.) without a full rebuild. The feature includes allowlist-based URL resolution, depth-first requires dependency chaining, page-lifetime deduplication, and a formatValue utility that fixes [object Object] rendering for DICOM PersonName attributes.

  • URL customization loader (CustomizationService.requires, validate.ts, resolve.ts): validates and resolves ?customization=name entries against configured prefixes, depth-first imports dependencies declared via requires, and applies their global payloads to CustomizationScope.Global. Already-loaded modules are skipped for the page lifetime; the loader runs once at bootstrap, not on SPA navigation.
  • init deduplication: Extension default/global modules are now merged at most once per page session via _extensionCustomizationModuleApplied, preventing repeated immutability-helper merges on mode transitions.
  • Overlay fixes: formatValue fixes the PersonName [object Object] display bug; preserveQueryParameters carries the customization key across navigation with arrayFormat: 'repeat' to keep qs.stringify output well-formed.

Confidence Score: 4/5

Safe to merge with awareness of the open duplicate-key issue in preserveQueryParameters that can cause doubled query-string values during worklist navigation.

The core customization loader is well-tested with good security controls. The main remaining concern is in preserveQueryParameters where getPreserveKeys concatenates base and custom keys without deduplication, which can cause doubled query-string values during navigation.

platform/app/src/utils/preserveQueryParameters.ts and platform/core/src/utils/formatValue.js

Important Files Changed

Filename Overview
platform/core/src/services/CustomizationService/CustomizationService.ts Core service gains URL customization loading, deduplication guards for extension modules on repeated init, and a bug-fix changing the GLOBAL_CUSTOMIZATION_MODIFIED broadcast from defaultCustomizations to globalCustomizations.
platform/core/src/services/CustomizationService/resolve.ts New URL resolution utility; correctly strips leading ./ and / from relative paths, and dispatches absolute-scheme bases directly.
platform/core/src/services/CustomizationService/validate.ts New validation layer; rejects full URLs, traversal segments, unknown prefixes, and unsafe name segments.
platform/app/src/utils/preserveQueryParameters.ts Adds customization to preserveKeys and switches to getAll-based multi-value preservation. getPreserveKeys concatenates base + custom keys without deduplication, which can cause doubled query-string values.
platform/core/src/utils/formatValue.js New utility to format DICOM attribute values. Returns null for arrays and unrecognized objects, which will silently hide multi-valued DICOM attributes.
extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx Uses formatValue to prevent [object Object] rendering for PersonName values.
platform/app/src/appInit.js Moves customizationService.init() into appInit after extension registration and calls applyWindowUrlCustomizations immediately after.
platform/app/src/routes/WorkList/WorkList.tsx Passes customizationService to preserveQueryStrings/preserveQueryParameters and adds arrayFormat: repeat to qs.stringify.

Comments Outside Diff (1)

  1. platform/core/src/services/CustomizationService/CustomizationService.ts, line 789-799 (link)

    P2 URL customization modules with only mode-scoped entries are silently swallowed

    _applyLoadedUrlCustomizationModules only applies payload.global. If an author writes a URL customization module that contains no global key, the module is imported and validated successfully but nothing is written to the service — with no warning. At a minimum a diagnostic should be logged when payload.global is absent so misconfigured modules are not silently ignored.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: platform/core/src/services/CustomizationService/CustomizationService.ts
    Line: 789-799
    
    Comment:
    **URL customization modules with only mode-scoped entries are silently swallowed**
    
    `_applyLoadedUrlCustomizationModules` only applies `payload.global`. If an author writes a URL customization module that contains no `global` key, the module is imported and validated successfully but nothing is written to the service — with no warning. At a minimum a diagnostic should be logged when `payload.global` is absent so misconfigured modules are not silently ignored.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. platform/core/src/services/CustomizationService/CustomizationService.ts, line 654-668 (link)

    P2 _collectUrlDependencyFromValue will attempt to URL-load any non-ohif.* customization field reference

    When a loaded module contains entries like { customization: 'corn.overlayItem' } (referencing a cornerstone customization type), _urlDependencyToRequest only skips names matching ^ohif\.[…]$. All other dot-namespaced extension customization identifiers pass validation (normalized to /default/corn.overlayItem) and trigger a network import attempt. In non-strict mode this fails silently with a warning and a wasted request for every such reference.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: platform/core/src/services/CustomizationService/CustomizationService.ts
    Line: 654-668
    
    Comment:
    **`_collectUrlDependencyFromValue` will attempt to URL-load any non-`ohif.*` `customization` field reference**
    
    When a loaded module contains entries like `{ customization: 'corn.overlayItem' }` (referencing a cornerstone customization type), `_urlDependencyToRequest` only skips names matching `^ohif\.[…]$`. All other dot-namespaced extension customization identifiers pass validation (normalized to `/default/corn.overlayItem`) and trigger a network import attempt. In non-strict mode this fails silently with a warning and a wasted request for every such reference.
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. platform/core/src/services/CustomizationService/CustomizationService.ts, line 510-522 (link)

    P2 URL customizations are loaded once at bootstrap and never refreshed on SPA navigation

    applyWindowUrlCustomizations is called once in appInit.js. Because _urlCustomizationLoaded is never cleared, if the user navigates to a URL with a different ?customization= parameter during client-side routing, previously-loaded customizations remain applied and new ones are not picked up. This may be intentional, but it is a non-obvious behavioral limit worth documenting — especially since the companion preserveQueryParameters change explicitly preserves the customization key across navigations.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: platform/core/src/services/CustomizationService/CustomizationService.ts
    Line: 510-522
    
    Comment:
    **URL customizations are loaded once at bootstrap and never refreshed on SPA navigation**
    
    `applyWindowUrlCustomizations` is called once in `appInit.js`. Because `_urlCustomizationLoaded` is never cleared, if the user navigates to a URL with a different `?customization=` parameter during client-side routing, previously-loaded customizations remain applied and new ones are not picked up. This may be intentional, but it is a non-obvious behavioral limit worth documenting — especially since the companion `preserveQueryParameters` change explicitly preserves the `customization` key across navigations.
    
    How can I resolve this? If you propose a fix, please make it concise.
  4. platform/app/src/routes/WorkList/WorkList.tsx, line 203-208 (link)

    P1 preserveQueryStrings now returns arrays; qs.stringify without arrayFormat will produce broken URLs

    preserveQueryStrings now stores every preserved key as an array (e.g., { configUrl: ['foo.js'] }), even when there is only one value. qs.stringify with default options uses arrayFormat: 'indices', serialising that as configUrl[0]=foo.js instead of configUrl=foo.js. Any consumer — the DICOM viewer, mode entry, or external tools — that parses configUrl from the worklist navigation URL as a plain string key will either get nothing or a key named configUrl[0]. Single-value preserved keys (configUrl, multimonitor, screenNumber, hangingProtocolId) were always strings before this PR; making them arrays without also specifying { arrayFormat: 'repeat' } (or equivalent) on every qs.stringify call is a regression.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: platform/app/src/routes/WorkList/WorkList.tsx
    Line: 203-208
    
    Comment:
    **`preserveQueryStrings` now returns arrays; `qs.stringify` without `arrayFormat` will produce broken URLs**
    
    `preserveQueryStrings` now stores every preserved key as an array (e.g., `{ configUrl: ['foo.js'] }`), even when there is only one value. `qs.stringify` with default options uses `arrayFormat: 'indices'`, serialising that as `configUrl[0]=foo.js` instead of `configUrl=foo.js`. Any consumer — the DICOM viewer, mode entry, or external tools — that parses `configUrl` from the worklist navigation URL as a plain string key will either get nothing or a key named `configUrl[0]`. Single-value preserved keys (`configUrl`, `multimonitor`, `screenNumber`, `hangingProtocolId`) were always strings before this PR; making them arrays without also specifying `{ arrayFormat: 'repeat' }` (or equivalent) on every `qs.stringify` call is a regression.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
platform/core/src/utils/formatValue.js:13-21
`formatValue` returns `null` for arrays and any object that lacks a string `.Alphabetic` property. DICOM multi-valued attributes (returned by dcmjs as JS arrays) will be silently hidden in overlay items. It is also possible to receive a `PersonName` object where `.Alphabetic` is itself a structured object rather than a plain string, which also falls through to `null`. A minimal guard for arrays would at least let a fallback display path kick in.

```suggestion
  if (typeof value === 'object' && typeof value.Alphabetic === 'string') {
    return value.Alphabetic;
  }

  if (Array.isArray(value) && value.length > 0) {
    const first = formatValue(value[0]);
    return first !== null ? first : null;
  }

  if (typeof value === 'number' || typeof value === 'boolean') {
    return String(value);
  }

  return null;
```

### Issue 2 of 2
platform/core/src/services/CustomizationService/CustomizationService.ts:393-411
**Dependency load failure does not block parent application in non-strict mode**

When a module lists a dependency in `requires` and that dependency fails to import (e.g. 404), `_urlCustomizationLoadOneBody` silently returns `null` for the dep, but the parent module's `depsChain.then(...)` still fires and pushes the parent into `newlyLoaded`. The parent's `global` customizations are then applied without the dependency's setup being in place. For the current veterinary example this is harmless (the chaining module has no `global`), but in non-trivial `requires` chains a failed transitive dep could leave the parent partially configured. Consider logging a warning at the parent level when any dep resolves to `null` in non-strict mode so authors have a clear signal.

Reviews (9): Last reviewed commit: "Merge remote-tracking branch 'origin/mas..." | Re-trigger Greptile

@netlify
Copy link
Copy Markdown

netlify Bot commented May 4, 2026

Deploy Preview for ohif-dev failed. Why did it fail? →

Name Link
🔨 Latest commit b9dcce7
🔍 Latest deploy log https://app.netlify.com/projects/ohif-dev/deploys/6a14a2826242180008d23cc6

Comment thread platform/core/src/services/CustomizationService/resolve.test.ts Dismissed
Comment thread platform/core/src/services/CustomizationService/resolve.test.ts Fixed
@cypress
Copy link
Copy Markdown

cypress Bot commented May 4, 2026

Viewers    Run #6312

Run Properties:  status check passed Passed #6312  •  git commit b9dcce79a0: Merge remote-tracking branch 'origin/master' into feat/customization-url-paramet...
Project Viewers
Branch Review feat/customization-url-parameter
Run status status check passed Passed #6312
Run duration 02m 23s
Commit git commit b9dcce79a0: Merge remote-tracking branch 'origin/master' into feat/customization-url-paramet...
Committer Bill Wallace
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 0
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 37
View all changes introduced in this branch ↗︎

Comment thread platform/core/src/services/CustomizationService/resolve.ts Outdated
Comment thread platform/app/src/utils/preserveQueryParameters.ts
@wayfarer3130 wayfarer3130 requested a review from sedghi May 7, 2026 22:14
Copy link
Copy Markdown
Member

@sedghi sedghi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not fully convinced the added value of this PR justifies the new security surface yet. This introduces URL-driven runtime JavaScript loading via ?customization=, which means a shared link can change viewer behavior and, depending on deployment config, potentially load executable code into the same browser context as OHIF. The validation does block obvious arbitrary URLs and path traversal, so this is not an immediate “any URL can execute code” issue. But the security boundary becomes the configured customization prefix and whoever can publish files there. If that directory/CDN is writable by the wrong party, this could become XSS-equivalent: token/session access, DICOM metadata exposure, UI manipulation, report tampering, or authenticated API abuse from the victim’s browser.

My current view is that this level of runtime configurability may be better kept in downstream forks or deployment-specific builds, where the deploying team can own the threat model and hosting controls explicitly. I’m not sure it should become a default upstream capability.

@wayfarer3130
Copy link
Copy Markdown
Contributor Author

I’m not fully convinced the added value of this PR justifies the new security surface yet. This introduces URL-driven runtime JavaScript loading via ?customization=, which means a shared link can change viewer behavior and, depending on deployment config, potentially load executable code into the same browser context as OHIF. The validation does block obvious arbitrary URLs and path traversal, so this is not an immediate “any URL can execute code” issue. But the security boundary becomes the configured customization prefix and whoever can publish files there. If that directory/CDN is writable by the wrong party, this could become XSS-equivalent: token/session access, DICOM metadata exposure, UI manipulation, report tampering, or authenticated API abuse from the victim’s browser.

My current view is that this level of runtime configurability may be better kept in downstream forks or deployment-specific builds, where the deploying team can own the threat model and hosting controls explicitly. I’m not sure it should become a default upstream capability.

If the CDN is writable by the wrong party, it doesn't matter what you do, they can replace the entire OHIF source control. At that point you are completely open.

What about adding a user configuration option to specifically and manually add customization prefixes rather than allowing it to be done via customization? That way we can default to one customization deploy somewhere that we control for the demonstration deployments, making note that is intended for demo purposes only, and same-host http /customization/ prefix path options so that we can deploy with a fixed deployment?

It is clear the advisory board wants SOMETHING that allows dynamic loading. I agree it needs to be controlled, but it also has to be external to the build process of OHIF, otherwise we will never meet the goals of allowing OHIF to be customized by non-developers.

Some other things we could consider:

  1. Allow JSON customizations from controlled locations for full setup
  2. Allow JS customizations to be loaded from given path names, but require a SHA sum to match. The list of valid SHA sums could come from a fixed list of valid/verified items
  3. Allow users to "add" new locations for SHA sum validators

That value of this is clearly extremely high given how many people on the meeting wanted something better. The only question becomes how to make it reasonably safe. It isn't fully safe, but neither is OHIF in the current configuration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants