Engineering articles
← All articles

Running a DICOM viewer under a strict Content Security Policy

Telegnost PACS: replacing a library the security policy blocked, instead of weakening the policy.

In short

The PACS portal blocks code generated at runtime. One imaging library needed it, so I replaced the library and made the build fail if the original ever comes back.

From Telegnost PACS · Technical lead

Architecture

  1. Next.js portal: worklists and reports
  2. Sandboxed iframe: forked OHIF viewer
  3. postMessage bridge with strict message checks
  4. NestJS DICOMweb proxy: per-study access

What was hard

The portal's Content Security Policy allows scripts only from its own origin (script-src 'self'). That rules out eval and new Function. It is the right policy for a clinical portal, and the viewer had to work inside it.

Segmentations, the DICOM objects that outline structures on an image, didn't. Loading one went through @cornerstonejs/adapters and dcmjs to a small array library, ndarray, which builds its classes with new Function. Under the policy, that throws an EvalError.

How I found it

Loading a segmentation in the viewer under the portal's policy failed with an EvalError. I followed it through the adapters and dcmjs to ndarray's constructor compiler. dcmjs also bundles its own inlined copy of ndarray, so there were two places to fix, not one.

Options I considered

Add 'unsafe-eval' to the policy. One line, but it opens every page of a clinical portal to code built at runtime.

Patch ndarray in node_modules. It doesn't survive a reinstall, and it doesn't reach the copy dcmjs inlines.

Write a drop-in ndarray that generates no code, point every import at it, and make the build fail if the original ever comes back. That is what shipped.

What shipped

A drop-in ndarray without code generation replaces the library through aliases in both webpack and rsbuild. For dcmjs's inlined copy, a webpack pre-loader swaps in the same implementation, and fails the build if dcmjs's bundle layout changes, so the swap can't silently stop working.

Packaging fails if any output chunk still contains ndarray's constructor compiler. Jest tests compare the replacement with the real ndarray on random shapes, negative strides, offsets, and chains of lo, hi, step, transpose and pick.

Around the viewer, both sides check every message. The viewer accepts a command only from the portal's origin and its own parent window, and only in a known shape: a LOAD_STUDY with valid DICOM UIDs and no extra keys. The portal accepts messages only from the iframe's window, validates them against a schema, and ignores a STUDY_LOADED for a study that isn't the one selected.

Images never come straight from the archive. The viewer asks a NestJS DICOMweb proxy, which checks that the study is visible to this user in this tenant and isn't locked by an import in progress. A positive check is cached, but the import lock is read again on every request, so a lock taken during the cache window can't leak image data.

Code excerptpacs-bridge/src/bridge.ts
const message = data as Record<string, unknown>;
const allowed = ['source', 'type', 'studyInstanceUid', 'seriesInstanceUid'];
if (
  message.source !== MESSAGE_SOURCE ||
  message.type !== 'LOAD_STUDY' ||
  !isValidDicomUid(message.studyInstanceUid) ||
  (message.seriesInstanceUid !== undefined &&
    !isValidDicomUid(message.seriesInstanceUid)) ||
  Object.keys(message).some(key => !allowed.includes(key))
) {
  return null;
}

export function isTrustedParentMessage(event, parentWindow, expectedOrigin) {
  return (
    event.origin === expectedOrigin &&
    event.source === parentWindow &&
    (parseLoadStudyMessage(event.data) !== null ||
      parseViewerBridgeCommandV2(event.data) !== null)
  );
}

Condensed from the viewer side of the bridge. A message that fails any check is ignored.

Result

Segmentations load under script-src 'self', with no exception in the policy, and the build fails if the code-generating constructor ever reappears. The forked viewer became the PACS viewer on 11 October 2026.

Contact Abdulrhman

Restoring DICOM answers in an embedded viewer

Browse all articles