Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 158 additions & 157 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions extensions/dicom-pdf/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../babel.config.js');
13 changes: 13 additions & 0 deletions extensions/dicom-pdf/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const base = require('../../jest.config.base.js');

module.exports = {
...base,
moduleNameMapper: {
...base.moduleNameMapper,
// Deep imports already name `src`, so they must be matched before the
// catch-all below appends a second one (e.g. `@ohif/core/src/utils/x`
// would otherwise resolve to `platform/core/src/utils/x/src`).
'^@ohif/([^/]+)/src/(.*)$': '<rootDir>/../../platform/$1/src/$2',
'@ohif/(.*)': '<rootDir>/../../platform/$1/src',
},
};
3 changes: 2 additions & 1 deletion extensions/dicom-pdf/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"dicom-parser": "1.8.21",
"hammerjs": "2.0.8",
"prop-types": "15.8.1",
"react": "18.3.1"
"react": "18.3.1",
"react-i18next": "12.3.1"
},
"dependencies": {
"@babel/runtime": "7.28.2",
Expand Down
19 changes: 13 additions & 6 deletions extensions/dicom-pdf/src/getSopClassHandlerModule.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { SOPClassHandlerId } from './id';
import { utils, Types as OhifTypes } from '@ohif/core';
import i18n from '@ohif/i18n';
import { normalizeDocumentMimeType } from './utils/displayableDocumentTypes';
import { loadDisplayableDocument } from './utils/loadDisplayableDocument';

const SOP_CLASS_UIDS = {
ENCAPSULATED_PDF: '1.2.840.10008.5.1.4.1.1.104.1',
Expand All @@ -9,17 +11,21 @@ const SOP_CLASS_UIDS = {
const sopClassUids = Object.values(SOP_CLASS_UIDS);

const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) => {
const dataSource = extensionManager.getActiveDataSource()[0];
return instances.map(instance => {
const { Modality, SOPInstanceUID } = instance;
const { SeriesDescription = 'PDF', MIMETypeOfEncapsulatedDocument } = instance;
const { SeriesNumber, SeriesDate, SeriesInstanceUID, StudyInstanceUID, SOPClassUID } = instance;
const renderedUrl = dataSource.retrieve.directURL({
// The declared type is only a claim. It is resolved against the displayable
// type allowlist, and the payload is re-wrapped in a Blob of the canonical
// type, so the instance cannot steer how the browser parses the document.
const mimeType = normalizeDocumentMimeType(MIMETypeOfEncapsulatedDocument) || 'application/pdf';

const documentParams = {
instance,
tag: 'EncapsulatedDocument',
defaultType: MIMETypeOfEncapsulatedDocument || 'application/pdf',
singlepart: 'pdf',
});
mimeType,
};
const getDocument = options => loadDisplayableDocument(documentParams, options);

const displaySet = {
//plugin: id,
Expand All @@ -35,7 +41,8 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager)
SOPClassUID,
referencedImages: null,
measurements: null,
renderedUrl: renderedUrl,
getDocument,
mimeType,
instances: [instance],
thumbnailSrc: null,
isDerivedDisplaySet: true,
Expand Down
129 changes: 129 additions & 0 deletions extensions/dicom-pdf/src/utils/displayableDocumentTypes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {
getDisplayableDocumentType,
matchesDocumentSignature,
normalizeDocumentMimeType,
} from './displayableDocumentTypes';

const bufferFrom = (bytes: number[]) => new Uint8Array(bytes).buffer;

describe('normalizeDocumentMimeType', () => {
it('lower-cases and strips parameters', () => {
expect(normalizeDocumentMimeType('Text/HTML; charset=UTF-8')).toBe('text/html');
});

it('trims surrounding whitespace', () => {
expect(normalizeDocumentMimeType(' application/pdf ')).toBe('application/pdf');
});

it('returns undefined for missing or empty values', () => {
expect(normalizeDocumentMimeType(undefined)).toBeUndefined();
expect(normalizeDocumentMimeType('')).toBeUndefined();
expect(normalizeDocumentMimeType(' ')).toBeUndefined();
expect(normalizeDocumentMimeType(42 as unknown as string)).toBeUndefined();
});
});

describe('getDisplayableDocumentType', () => {
it('renders pdf through <object>, which cannot be sandboxed', () => {
const documentType = getDisplayableDocumentType('application/pdf');

expect(documentType).toMatchObject({ mimeType: 'application/pdf', strategy: 'object' });
expect(documentType?.sandbox).toBeUndefined();
});

it('renders html through a fully restricted sandboxed iframe', () => {
expect(getDisplayableDocumentType('text/html')).toMatchObject({
mimeType: 'text/html',
strategy: 'iframe',
sandbox: '',
});
});

it('folds application/html onto text/html', () => {
expect(getDisplayableDocumentType('application/html')).toMatchObject({
mimeType: 'text/html',
strategy: 'iframe',
});
});

it('folds non-standard pdf spellings onto application/pdf', () => {
for (const alias of ['application/x-pdf', 'application/acrobat', 'text/pdf']) {
expect(getDisplayableDocumentType(alias)).toMatchObject({
mimeType: 'application/pdf',
strategy: 'object',
});
}
});

it('resolves types that carry parameters', () => {
expect(getDisplayableDocumentType('text/html;charset=iso-8859-1')).toMatchObject({
mimeType: 'text/html',
});
});

it('rejects types that are not on the allowlist', () => {
for (const mimeType of [
'application/octet-stream',
'image/svg+xml',
'application/javascript',
'text/rtf',
'application/msword',
undefined,
'',
]) {
expect(getDisplayableDocumentType(mimeType)).toBeUndefined();
}
});
});

describe('matchesDocumentSignature', () => {
const pdf = getDisplayableDocumentType('application/pdf');
const html = getDisplayableDocumentType('text/html');

it('accepts a payload that starts with the type magic number', () => {
// "%PDF-1.4"
const payload = bufferFrom([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]);

expect(matchesDocumentSignature(pdf, payload)).toBe(true);
});

// "%PDF-1.4"
const pdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34];

it('accepts a pdf whose header follows a UTF-8 BOM', () => {
const payload = bufferFrom([0xef, 0xbb, 0xbf, ...pdfHeader]);

expect(matchesDocumentSignature(pdf, payload)).toBe(true);
});

it('accepts a pdf whose header starts at the last searched offset', () => {
// Mainstream readers scan roughly the first 1024 bytes for "%PDF-", so a
// header this far in is still a file they open.
const payload = bufferFrom([...new Array(1024).fill(0x20), ...pdfHeader]);

expect(matchesDocumentSignature(pdf, payload)).toBe(true);
});

it('rejects a pdf whose header starts past the search window', () => {
const payload = bufferFrom([...new Array(1025).fill(0x20), ...pdfHeader]);

expect(matchesDocumentSignature(pdf, payload)).toBe(false);
});

it('rejects a payload declared as pdf that is actually html', () => {
// "<html>"
const payload = bufferFrom([0x3c, 0x68, 0x74, 0x6d, 0x6c, 0x3e]);

expect(matchesDocumentSignature(pdf, payload)).toBe(false);
});

it('rejects a payload shorter than the signature', () => {
expect(matchesDocumentSignature(pdf, bufferFrom([0x25, 0x50]))).toBe(false);
expect(matchesDocumentSignature(pdf, bufferFrom([]))).toBe(false);
});

it('accepts any payload for types with no reliable magic number', () => {
expect(matchesDocumentSignature(html, bufferFrom([0x3c, 0x68]))).toBe(true);
expect(matchesDocumentSignature(html, bufferFrom([]))).toBe(true);
});
});
170 changes: 170 additions & 0 deletions extensions/dicom-pdf/src/utils/displayableDocumentTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* The set of encapsulated-document MIME types this extension is willing to put
* in front of a user, and how each one is embedded.
*
* MIMETypeOfEncapsulatedDocument is supplied by whoever produced the instance,
* so it is treated here as a claim to be checked rather than an instruction to
* be followed. A type that is not on this list is not rendered at all.
*/

/**
* How a document is embedded in the viewport.
*
* 'object' - <object>. The browsers' built-in PDF viewers refuse to run inside
* a sandboxed browsing context: in Chrome a PDF renders under no sandbox at
* all and under none of the token combinations, including the fully
* permissive `allow-scripts allow-same-origin`. PDFs therefore cannot be
* sandboxed, and their safety rests entirely on the type guarantee that
* loadDisplayableDocument applies (canonical Blob type + signature check).
*
* 'iframe' - <iframe sandbox>. Markup types render correctly inside a sandbox,
* so they get one. The default is the empty sandbox: no tokens, scripts
* inert, opaque origin, no access to the viewer's storage or DOM.
*/
export type DocumentEmbedStrategy = 'object' | 'iframe';

export type DisplayableDocumentType = {
/** Canonical type forced onto the Blob handed to the browser. */
mimeType: string;
strategy: DocumentEmbedStrategy;
/** sandbox attribute value; only meaningful for the 'iframe' strategy. */
sandbox?: string;
/** Magic number every payload of this type has to carry, when the type has a
* reliable one. Types without one rely on the sandbox instead. */
signature?: number[];
/** How far into the payload the signature is allowed to start. Absent means
* offset 0 - see PDF_SIGNATURE_SEARCH_LIMIT for why a type wants slack. */
signatureSearchLimit?: number;
};

/**
* Real files do not always put their magic number at byte 0, and readers that
* accept them anyway are the reason such files exist in the wild.
*
* PDF: ISO 32000-1 requires "%PDF-" at the start of the file, but Adobe's own
* implementation note relaxes this to anywhere within the first 1024 bytes, and
* every mainstream reader follows suit. Producers that write a UTF-8 BOM, or
* that prepend junk before the header, therefore produce files that open
* everywhere except here. Matching the 1024-byte allowance keeps the check
* doing its actual job - catching a payload that is not a PDF at all, which is
* how a document declared as PDF but containing markup gets rejected - without
* failing valid documents over leading bytes the renderer will skip anyway.
*/
const PDF_SIGNATURE_SEARCH_LIMIT = 1024;

/**
* Canonical entries, keyed by canonical MIME type. Exported so downstream
* deployments can extend the list; adding an entry means asserting both that
* the browser renders that type inline and that the chosen strategy contains it.
*/
export const DISPLAYABLE_DOCUMENT_TYPES: Record<string, DisplayableDocumentType> = {
'application/pdf': {
mimeType: 'application/pdf',
strategy: 'object',
signature: [0x25, 0x50, 0x44, 0x46, 0x2d], // "%PDF-"
signatureSearchLimit: PDF_SIGNATURE_SEARCH_LIMIT,
},
'text/html': {
mimeType: 'text/html',
strategy: 'iframe',
sandbox: '',
},
'application/xhtml+xml': {
mimeType: 'application/xhtml+xml',
strategy: 'iframe',
sandbox: '',
},
'text/xml': {
mimeType: 'text/xml',
strategy: 'iframe',
sandbox: '',
},
'application/xml': {
mimeType: 'application/xml',
strategy: 'iframe',
sandbox: '',
},
'text/plain': {
mimeType: 'text/plain',
strategy: 'iframe',
sandbox: '',
},
};

/**
* Non-standard spellings seen in real instances, folded onto their canonical
* entry. Accepting an alias is safe because the canonical entry decides both
* the Blob type and the signature the payload has to satisfy.
*/
export const DOCUMENT_MIME_TYPE_ALIASES: Record<string, string> = {
'application/html': 'text/html',
'application/x-pdf': 'application/pdf',
'application/acrobat': 'application/pdf',
'text/pdf': 'application/pdf',
'application/xhtml': 'application/xhtml+xml',
};

/**
* Lower-cases and strips any parameters (`text/html; charset=utf-8`).
*/
export function normalizeDocumentMimeType(rawMimeType?: string): string | undefined {
if (typeof rawMimeType !== 'string') {
return undefined;
}

const normalized = rawMimeType.split(';')[0].trim().toLowerCase();

return normalized || undefined;
}

/**
* Resolves a declared MIME type to its allowlist entry, or undefined when the
* type is not one this extension will display.
*/
export function getDisplayableDocumentType(
rawMimeType?: string
): DisplayableDocumentType | undefined {
const normalized = normalizeDocumentMimeType(rawMimeType);

if (!normalized) {
return undefined;
}

const canonical = DOCUMENT_MIME_TYPE_ALIASES[normalized] ?? normalized;

return DISPLAYABLE_DOCUMENT_TYPES[canonical];
}

/**
* Checks a payload against its type's magic number, allowing the signature to
* start anywhere within `signatureSearchLimit` bytes of the payload. Types that
* have no reliable signature pass, since for those the sandbox rather than the
* content check is what contains the document.
*/
export function matchesDocumentSignature(
documentType: DisplayableDocumentType,
payload: ArrayBuffer
): boolean {
const { signature, signatureSearchLimit = 0 } = documentType;

if (!signature?.length) {
return true;
}

if (payload.byteLength < signature.length) {
return false;
}

// Only the window the signature could still start in needs reading, and it is
// bounded, so an oversized document costs the same as a small one.
const lastStart = Math.min(signatureSearchLimit, payload.byteLength - signature.length);
const head = new Uint8Array(payload, 0, lastStart + signature.length);

for (let start = 0; start <= lastStart; start++) {
if (signature.every((byte, index) => head[start + index] === byte)) {
return true;
}
}

return false;
}
Loading
Loading