Skip to content

ADR-014: Integrated, synchronized study PDF viewer

Status

In-Review. This records a default-off implementation and staged-rollout design; it does not authorize enabling the feature in any environment.

Context

Study review currently exposes the API-projected linkToPdf as an external browser tab/window. That URL is the established authority: it comes from PdfLinkBuilder and may point either to SyRF-managed storage or an externally hosted PDF. It may also expire or be revoked independently of the Angular application. The web application already depends on Mozilla PDF.js 4.10.38 (Apache-2.0) and has a bundled module worker, but its old PDF-tools components are not a study-review viewer.

Reviewers primarily use desktop and multi-monitor layouts. They need a PDF window that follows the committed next/previous study navigation without making another client-side source of truth for PDF authorization.

Decision

  1. Add a default-off integratedPdfViewer web feature flag. Flag-off retains the existing, accessible external View PDF link unchanged in both legacy review and AF2.
  2. When enabled, open a named SyRF-owned /pdf-viewer window only from a user gesture. The browser URL contains a random, serializer-neutral per-window capability in its fragment, never a PDF URL or study identifier. It deliberately avoids a hyphenated UUID format because SyRF's global URL serializer encodes UUIDs.
  3. Use an opener-bound postMessage protocol, not ambient BroadcastChannel state. The main window accepts a viewer message only when origin is its exact origin, source is its current popup reference, and the capability matches. Replies use that exact origin. The viewer ignores every other origin, source and capability. This gives refresh/reconnect a narrow lifecycle without making the study or URL routable.
  4. The main review host remains the sole authority. It projects only its already-authorized current study's id, title and linkToPdf into the viewer after the handshake. A change in current-study state increments a revision and replaces the viewer state; PDF URLs are never persisted in route, storage or query state.
  5. The viewer destroys any active PDF.js loading task/document before accepting a newer revision. It displays explicit loading, unavailable and error states, and provides the existing direct open/download link as an accessible fallback. The integrated viewer receives only same-origin PDF URLs: legacy or externally-hosted URLs remain in that direct fallback path rather than being fetched from a different origin. The popup does not infer, synthesize or refresh a URL; an expired/revoked URL remains an error until the main window authorizes a newer state.
  6. The viewer sends a short heartbeat. If its opener stops confirming the exact capability (for example logout, review exit or main-window refresh), it clears the rendered document and URL. The main host clears state on destroy. Popup blocking is reported to the user while preserving the fallback link.
  7. Retain PDF.js 4.10.38 for this change. The maintained upstream release is newer, but the current 6.1.200 package requires Node >=22.13 while this repository's installed tooling is Node-20-compatible. Upgrade PDF.js separately after a browser/build compatibility and security review; do not couple that breaking dependency jump to the viewer rollout.

Consequences

  • Study navigation, API authorization and existing PDF URL generation are not changed.
  • The feature can be enabled gradually through generated configuration, but defaults false in code and Helm values.
  • A popup has deliberately narrower resilience than a full second application tab: it cannot retain a document after the coordinating main window disappears.
  • PDF.js rendering stays in a SyRF page, avoiding an untrusted third-party viewer wrapper. The direct fallback remains available for browser accessibility tooling and failed popups.
  • A server-side PDF proxy is deliberately out of scope: it would need separate authorization and threat modelling for SSRF, redirects, credentials, byte ranges and revocation behaviour.

Alternatives considered

  • BroadcastChannel-only coordination: rejected because any same-origin participant can hear broadcast state and it does not bind a viewer to the opener that created it.
  • PDF URL/study route parameters: rejected because history, refresh and copied URLs would retain sensitive, potentially expired authority outside the main review lifecycle.
  • Iframe/native browser rendering in the main form: rejected for this desktop-first slice; it competes with review width and does not serve multi-monitor reviewers as well.
  • Upgrade to newest PDF.js now: rejected as an unrelated Node/toolchain compatibility change.

Rollout and validation plan

  1. Keep the generated flag false everywhere and validate popup-blocked, unavailable, stale-load, close/reopen, rapid-navigation and logout/review-exit behaviour with focused automated tests.
  2. Enable only in a non-production environment after manual multi-monitor and keyboard/screen reader checks against managed, external and expired URLs.
  3. Obtain explicit authorization for a narrowly scoped production cohort; monitor popup failures, PDF load failures and navigation misalignment before widening. No rollout is implied by this ADR or PR.

Addendum (2026-09-01): fixed-upstream same-origin study PDF proxy

The original decision left a server-side PDF proxy out of scope, and that left the feature inert. Every environment configures the backend pdfSettings.baseUrl as https://ecrf1.clinicaltrials.ed.ac.uk/camarades/, which is cross-origin to every SyRF web host, so StudyPdfViewerCoordinatorService classified every managed PDF as external and open() always returned fallback-only. The viewer could never render a managed study PDF anywhere.

Decision

  1. The web container serves managed study PDFs same-origin through an nginx location ^~ /study-pdfs/ block that proxies to one fixed upstream, taken from the deployment environment variable SYRF__StudyPdfUpstreamBaseUrl and substituted by the official nginx image entrypoint at container start. nginx maps split this configured URL into its origin and base path. A variable proxy_pass containing only that origin resolves DNS when a PDF is requested, using the container's nameservers supplied by the image's NGINX_ENTRYPOINT_LOCAL_RESOLVERS helper. Answers are cached for 30 seconds, with a five-second resolver timeout. Unresolvable PDF DNS returns 502 for that request without preventing nginx startup, application assets, or the independent API proxy from working. The Docker integration test exercises both a resolvable hostname and an unresolvable optional upstream.
  2. One env-mapping.yaml value (studyPdf.upstreamBaseUrl, default https://ecrf1.clinicaltrials.ed.ac.uk/camarades/) feeds both consumers: that container environment variable and the browser configuration key studyPdfUpstreamBaseUrl. The web container images (Dockerfile, Dockerfile.full, and the CI test image) bake that same canonical value in as an ENV default, so an image run outside Helm - docker run, compose, a locally built image - still renders a valid nginx config and starts, instead of leaving an undefined ${SYRF__StudyPdfUpstreamBaseUrl} literal that nginx refuses to load. Helm still overrides the variable at runtime, and CI starts one container with no override and asserts nginx -t passes there.
  3. StudyPdfViewerCoordinatorService.setStudy rewrites a linkToPdf that begins with the configured upstream onto study-pdfs/<suffix> on the application origin, preserving path, query and fragment (the viewer reads #page=), and treats that study as internal. Every other URL keeps the existing exact-origin classification, and so does a managed URL whose suffix is empty, slash-led, query-only or fragment-only, because that would proxy the upstream root rather than a document below it. The configured value is accepted only in the grammar the nginx maps actually parse - no surrounding whitespace, a lowercase http:// or https:// scheme, and a trailing slash - because nginx substitutes the deployment string verbatim, so any other spelling (HTTPS://, ftp://, a padded value) renders a proxy that cannot serve anything and the browser must not rewrite links onto it. The transferred state carries the rewritten URL as pdfUrl (what PDF.js fetches) and the study's own URL as directUrl, so the popup's "Open or download PDF" fallback link stays a direct navigation to the managed host rather than being routed back through the proxy it exists to bypass. directUrl is optional in the message contract and resolved as directUrl ?? pdfUrl: during a rolling deploy a stage-review tab still running the previous coordinator omits it, and a freshly loaded popup must render that state rather than rejecting every message. An absent or empty browser configuration value disables the rewrite entirely, so local development without the proxy behaves exactly as before. Both the chart default and the _env-blocks.tpl default deliberately re-substitute the canonical upstream for an empty-string override. Disabling the feature is the integratedPdfViewer flag's job, never an empty upstream.

Security analysis

  • No SSRF pivot. The upstream is deployment configuration baked into the rendered nginx config, never user input. Only the path below /study-pdfs/ varies; a caller cannot choose a host, port or scheme.
  • Path-only attack surface, closed by normalization. nginx resolves merged slashes and . / .. segments in $uri - percent-encoded ones included - before location matching, so a traversal attempt escaping /study-pdfs/ never reaches the proxy location at all. Inside that location, a rewrite ... break replaces the prefix with the fixed configured base path and retains the normalized suffix. proxy_pass has no URI, so nginx escapes the rewritten path and preserves the query instead of sending decoded spaces or interpreting an encoded question mark as a query delimiter. The trailing-slash base contract keeps requests below the upstream base path. CI asserts literal and percent-encoded traversal, nested paths, encoded spaces and punctuation, encoded slashes, and query preservation.
  • Credentials stripped in both directions. proxy_set_header Cookie "" and proxy_set_header Authorization "" mean the upstream never receives a SyRF session, and proxy_hide_header Set-Cookie means upstream cookies never reach the browser, and proxy_hide_header Clear-Site-Data means a compromised or repurposed upstream cannot wipe SyRF's own cookies and storage through a response relayed on this origin, and proxy_hide_header Strict-Transport-Security means it cannot clear or pin the host's HSTS policy either (HSTS is per host, not per path).
  • No upstream internal redirects. proxy_ignore_headers X-Accel-Redirect prevents a PDF response from jumping internally to another nginx location such as /api, where the original browser session credentials would otherwise be forwarded. The header is also hidden by nginx's default response-header policy. CI proves the API credential-sentinel target is live, then verifies that a malicious PDF response neither reaches it nor bypasses the PDF response headers.
  • Read-only. limit_except GET { deny all; } permits GET and HEAD only; POST/PUT/DELETE are refused with 403.
  • Redirects fail closed before reaching the browser. proxy_intercept_errors on and an error_page mapping replace upstream 301, 302, 303, 307 and 308 responses with a local 502 response. Its fixed plain-text body contains no upstream content, and no upstream Location is exposed. proxy_hide_header Location also suppresses that header on other responses. Merely disabling redirect rewriting is insufficient: a relative or absolute same-origin Location could make the browser request /api with its SyRF session cookie. The proxy never follows a redirect itself, and the browser receives none to follow. Normal PDF 304 cache validation remains unchanged. CI verifies all five redirect statuses with actual relative and absolute same-origin targets, a redirect back to the fixed upstream, and a conditional PDF request. The API credential-sentinel request count stays unchanged throughout these attempts.
  • Intercepted upstream 5xx stays inside this location's response contract. proxy_intercept_errors on (required for the redirect interception below) also hands upstream 500/502/503/504 responses to error_page. Left to the server-wide error_page 500 502 503 504 /50x.html, an upstream outage would answer a /study-pdfs/ request with an HTML error page - the shared one where it exists, nginx's built-in page where it does not - whose content is not under this location's control. A location-level error_page 500 502 503 504 = @syrf_study_pdf_upstream_error answers with the same neutralized plain-text 502 the redirect rejection uses: a fixed body carrying no upstream content, X-Content-Type-Options: nosniff and Content-Disposition: attachment. (Those two always headers already survived interception; the guarantee added here is that no HTML body is ever served under this path, whatever the deployed image's error pages contain.) The 3xx interception is unchanged, and unresolvable-DNS 502s keep failing exactly as before. CI proves the fixture upstream really answers 503 and that the shared page exists, then asserts the proxied response carries both headers, the fixed plain-text body, and no HTML.
  • No delayed redirect via Refresh. proxy_hide_header Refresh strips an upstream Refresh header. It is a redirect by another name that user agents honour on a relayed response, and it rides on a 200 rather than on any of the 3xx statuses the error_page interception above covers, so without this a repurposed upstream could still send the browser to /api carrying its SyRF session. CI proves the fixture upstream emits one and that it never reaches the browser.
  • No upstream reporting policy on the SyRF origin. proxy_hide_header NEL, proxy_hide_header Report-To and proxy_hide_header Reporting-Endpoints stop the upstream installing origin-wide reporting endpoints of its own. Those policies apply to the whole SyRF origin, not just this location, and outlive the response that set them, so a relayed one would have the browser post SyRF network errors - request URLs included - to a collector the PDF host chose. CI proves the fixture upstream emits all three and that none of them survives the proxy.
  • Upstream content cannot execute on the application origin. The proxy relays only PDFs by contract, but the upstream could serve active content (an HTML error page, a repurposed path). Every proxied response carries X-Content-Type-Options: nosniff, and any response whose upstream Content-Type is not application/pdf is forced to Content-Disposition: attachment, so HTML/SVG from the upstream downloads instead of rendering with the SyRF origin's authority. The upstream's own Content-Disposition is hidden first, so an upstream inline cannot ride alongside the forced attachment as a duplicate header for the browser to arbitrate. Asserted end to end in the CI nginx test against an HTML fixture.
  • No new disclosure authority. The managed PDF host is already public by design (ADR-015: storage boundaries prevent sibling discovery and derivation, not fetches by a client that already knows a valid URL), and linkToPdf disclosure is controlled at the authenticated API. The proxy therefore adds no authorization requirement of its own: anyone who can fetch /study-pdfs/<suffix> could already fetch <upstream><suffix> directly. If managed PDFs ever gain per-URL credentials or become non-public, this posture must be revisited together with the logging point below.
  • No credentials in proxy logs today. Managed linkToPdf URLs carry no tokens or signatures (PdfLinkBuilder composes plain base-plus-path URLs), so request logging on this location leaks nothing. The coordinator preserves query strings defensively; if URL signing is ever introduced, add log redaction (or access_log off) for this location in the same change.
  • Verified TLS to the upstream. proxy_ssl_server_name on plus proxy_ssl_verify on against the nginx:alpine CA bundle at /etc/ssl/certs/ca-certificates.crt.
  • Byte ranges preserved. nginx forwards client request headers, Range included, unchanged by default, and no proxy caching is configured on this location, so PDF.js range requests keep working (asserted end to end in the CI nginx test, which observes a 206).

Consequences

  • Managed study PDFs become fetchable by the viewer; externally hosted PDFs keep the direct-link fallback exactly as before.
  • The configured upstream must end with a trailing slash. A URL missing that slash is rejected at PDF request time rather than preventing the web container from starting.
  • studyPdf.upstreamBaseUrl must match the backend pdfSettings.baseUrl of the same deployment. A mismatch is fail-safe rather than dangerous - no link matches the prefix, so every study keeps the direct-link fallback exactly as it behaves today - but the viewer stays inert until the two agree.
  • Rollout authority is unchanged: integratedPdfViewer remains generated and default-off, and this addendum authorizes no environment enablement. The nginx location itself is static and serves /study-pdfs/ whether or not the flag is on; the flag gates only the viewer that uses it. That is acceptable under the "no new disclosure authority" analysis above (the proxied host is already public), and it is why the proxy is proxy-only: no authorization decision is made in nginx.

References