fix: recipe config cleanup, report-only CSP, logout redirect validation (#6124)

This commit is contained in:
Alireza 2026-07-07 13:18:46 -04:00 committed by GitHub
parent 973631b7e8
commit f8546ce0e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 446 additions and 75 deletions

View File

@ -23,6 +23,7 @@ concurrency:
jobs: jobs:
playwright-tests: playwright-tests:
timeout-minutes: 120 timeout-minutes: 120
environment: fork-pr-approval
runs-on: [self-hosted, nashua] runs-on: [self-hosted, nashua]
strategy: strategy:
fail-fast: false fail-fast: false

View File

@ -24,6 +24,14 @@
X-Frame-Options = "DENY" X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block" X-XSS-Protection = "1; mode=block"
# Report-Only CSP: observational only, enforces nothing. Watch browser
# consoles for violation reports before ever promoting this to an
# enforcing Content-Security-Policy header. cdnjs.cloudflare.com is for
# the Rollbar snippet in html-templates/rollbar.html (demo builds);
# connect-src stays broad because deployments point the viewer at
# arbitrary DICOMweb origins.
Content-Security-Policy-Report-Only = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https: blob:; worker-src 'self' blob:; object-src 'self' blob:; frame-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
cache-control = ''' cache-control = '''
max-age=0, max-age=0,
no-cache, no-cache,

View File

@ -0,0 +1,7 @@
# Copy this file to .env next to docker-compose.yml and fill in strong
# values. docker compose refuses to start while either is unset.
KEYCLOAK_ADMIN_PASSWORD=
# POSTGRES_PASSWORD is the single PostgreSQL credential: it provisions the
# keycloak database role and Keycloak reuses it to connect (KC_DB_PASSWORD is
# derived from it in docker-compose.yml), so there is one value, not two.
POSTGRES_PASSWORD=

View File

@ -0,0 +1,42 @@
# Security notes for this recipe
## Secrets
This recipe no longer ships working credential values. Before `docker compose up`:
- Generate a fresh OAuth client secret for the `ohif_viewer` client and use it
to replace the `REPLACE_WITH_A_GENERATED_CLIENT_SECRET` placeholder in BOTH
`config/ohif-keycloak-realm.json` and `config/oauth2-proxy.cfg` (the two
values must match). To generate one after the realm is imported: Keycloak
admin console -> Clients -> ohif_viewer -> Credentials -> Regenerate.
- Copy `.env.example` to `.env` and set `POSTGRES_PASSWORD` and
`KEYCLOAK_ADMIN_PASSWORD` to strong values. `docker compose` refuses to
start while either is unset. `POSTGRES_PASSWORD` is the single PostgreSQL
credential - it both provisions the `keycloak` database role and is what
Keycloak uses to connect (the compose file derives `KC_DB_PASSWORD` from it),
so there is one database password to set, not two.
## Rotate if you deployed from an earlier checkout
Earlier versions of this recipe committed a fixed client secret and default
admin/database passwords to the public repository. A committed secret stays
burned even after this change, so any deployment created from an earlier
checkout must rotate:
- the `ohif_viewer` client secret (Keycloak admin console -> Clients ->
ohif_viewer -> Credentials -> Regenerate, then update
`config/oauth2-proxy.cfg` to match), and
- the Keycloak admin and PostgreSQL passwords. Note that changing
`POSTGRES_PASSWORD` in `.env` alone does not re-password an existing
database: Postgres only applies it when the `postgres_data` volume is first
initialized. To actually rotate it on an existing deployment, either
`ALTER ROLE keycloak WITH PASSWORD ...` inside the running database or
recreate the `postgres_data` volume.
## CORS
The wildcard `Access-Control-Allow-Origin: *` defaults were removed from
`config/nginx.conf`. The viewer is served by the same nginx as the dcm4chee
proxy, so same-origin deployments need no CORS headers. If you host the viewer
on a different origin, use the commented explicit-origin example in the nginx
config - never `*` on an authenticated endpoint that serves PHI.

View File

@ -54,6 +54,15 @@ http {
gzip_comp_level 9; gzip_comp_level 9;
etag on; etag on;
# Content-Security-Policy: test in Report-Only mode first and watch
# the browser console for violations before switching the header name
# to the enforcing Content-Security-Policy. Note: nginx add_header does
# not merge into a location that sets its own add_header - if you
# promote this to an active header, re-declare it inside every location
# block that already uses add_header (several proxy locations here do)
# or it will silently not apply there.
# add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https: blob:; worker-src 'self' blob:; object-src 'self' blob:; frame-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
location /sw.js { location /sw.js {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
proxy_cache_bypass $http_pragma; proxy_cache_bypass $http_pragma;
@ -101,19 +110,25 @@ http {
expires 0; expires 0;
add_header Cache-Control private; add_header Cache-Control private;
add_header 'Access-Control-Allow-Origin' '*' always; # CORS: this recipe serves the viewer from this same nginx, so
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; # browser requests to this endpoint are same-origin and need NO
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always; # CORS headers at all. If you host the viewer on a different
# origin, allow that origin explicitly and only that origin -
if ($request_method = OPTIONS) { # never use '*' on an authenticated endpoint that serves PHI.
add_header 'Access-Control-Allow-Origin' '*'; # Cross-origin example (response headers plus OPTIONS preflight):
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; # add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept'; # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Max-Age' 1728000; # add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always;
add_header 'Content-Type' 'text/plain; charset=utf-8'; #
add_header 'Content-Length' 0; # if ($request_method = OPTIONS) {
return 204; # add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com';
} # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept';
# add_header 'Access-Control-Max-Age' 1728000;
# add_header 'Content-Type' 'text/plain; charset=utf-8';
# add_header 'Content-Length' 0;
# return 204;
# }
rewrite ^/pacs/(.*) /dcm4chee-arc/aets/DCM4CHEE/rs/$1 break; rewrite ^/pacs/(.*) /dcm4chee-arc/aets/DCM4CHEE/rs/$1 break;
proxy_pass http://arc:8080; proxy_pass http://arc:8080;
@ -155,19 +170,8 @@ http {
expires 0; expires 0;
add_header Cache-Control private; add_header Cache-Control private;
add_header 'Access-Control-Allow-Origin' '*' always; # CORS: same-origin deployment - no CORS headers needed here; see
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; # the note on the /pacs/ location above before adding any.
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always;
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://arc:8080; proxy_pass http://arc:8080;
} }

View File

@ -4,7 +4,7 @@ email_domains=["*"]
cookie_secure="false" cookie_secure="false"
cookie_expire="9m30s" cookie_expire="9m30s"
cookie_refresh="5m" cookie_refresh="5m"
client_secret="2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg" client_secret="REPLACE_WITH_A_GENERATED_CLIENT_SECRET"
client_id="ohif_viewer" client_id="ohif_viewer"
redirect_url="http://YOUR_DOMAIN/oauth2/callback" redirect_url="http://YOUR_DOMAIN/oauth2/callback"

View File

@ -686,7 +686,7 @@
"enabled": true, "enabled": true,
"alwaysDisplayInConsole": false, "alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret", "clientAuthenticatorType": "client-secret",
"secret": "2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg", "secret": "REPLACE_WITH_A_GENERATED_CLIENT_SECRET",
"redirectUris": [ "redirectUris": [
"http://127.0.0.1/oauth2/callback" "http://127.0.0.1/oauth2/callback"
], ],

View File

@ -94,14 +94,17 @@ services:
KC_DB_URL: 'jdbc:postgresql://postgres:5432/keycloak' KC_DB_URL: 'jdbc:postgresql://postgres:5432/keycloak'
KC_DB_SCHEMA: public KC_DB_SCHEMA: public
KC_DB_USERNAME: keycloak KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: password # Keycloak authenticates to Postgres as the keycloak role provisioned
# below, so this must be the same value as POSTGRES_PASSWORD. Sourced
# from the single POSTGRES_PASSWORD variable to keep them in lockstep.
KC_DB_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in your .env}
KC_HOSTNAME_ADMIN_URL: http://YOUR_DOMAIN/keycloak/ KC_HOSTNAME_ADMIN_URL: http://YOUR_DOMAIN/keycloak/
KC_HOSTNAME_URL: http://YOUR_DOMAIN/keycloak/ KC_HOSTNAME_URL: http://YOUR_DOMAIN/keycloak/
KC_HOSTNAME_STRICT_BACKCHANNEL: true KC_HOSTNAME_STRICT_BACKCHANNEL: true
KC_HOSTNAME_STRICT_HTTPS: false KC_HOSTNAME_STRICT_HTTPS: false
KC_HTTP_ENABLED: true KC_HTTP_ENABLED: true
KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD in your .env}
KC_HEALTH_ENABLED: true KC_HEALTH_ENABLED: true
KC_METRICS_ENABLED: true KC_METRICS_ENABLED: true
KC_PROXY: edge KC_PROXY: edge
@ -139,7 +142,7 @@ services:
environment: environment:
POSTGRES_DB: keycloak POSTGRES_DB: keycloak
POSTGRES_USER: keycloak POSTGRES_USER: keycloak
POSTGRES_PASSWORD: password POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in your .env}
restart: unless-stopped restart: unless-stopped
networks: networks:
- default - default

View File

@ -0,0 +1,7 @@
# Copy this file to .env next to docker-compose.yml and fill in strong
# values. docker compose refuses to start while either is unset.
KEYCLOAK_ADMIN_PASSWORD=
# POSTGRES_PASSWORD is the single PostgreSQL credential: it provisions the
# keycloak database role and Keycloak reuses it to connect (KC_DB_PASSWORD is
# derived from it in docker-compose.yml), so there is one value, not two.
POSTGRES_PASSWORD=

View File

@ -0,0 +1,42 @@
# Security notes for this recipe
## Secrets
This recipe no longer ships working credential values. Before `docker compose up`:
- Generate a fresh OAuth client secret for the `ohif_viewer` client and use it
to replace the `REPLACE_WITH_A_GENERATED_CLIENT_SECRET` placeholder in BOTH
`config/ohif-keycloak-realm.json` and `config/oauth2-proxy.cfg` (the two
values must match). To generate one after the realm is imported: Keycloak
admin console -> Clients -> ohif_viewer -> Credentials -> Regenerate.
- Copy `.env.example` to `.env` and set `POSTGRES_PASSWORD` and
`KEYCLOAK_ADMIN_PASSWORD` to strong values. `docker compose` refuses to
start while either is unset. `POSTGRES_PASSWORD` is the single PostgreSQL
credential - it both provisions the `keycloak` database role and is what
Keycloak uses to connect (the compose file derives `KC_DB_PASSWORD` from it),
so there is one database password to set, not two.
## Rotate if you deployed from an earlier checkout
Earlier versions of this recipe committed a fixed client secret and default
admin/database passwords to the public repository. A committed secret stays
burned even after this change, so any deployment created from an earlier
checkout must rotate:
- the `ohif_viewer` client secret (Keycloak admin console -> Clients ->
ohif_viewer -> Credentials -> Regenerate, then update
`config/oauth2-proxy.cfg` to match), and
- the Keycloak admin and PostgreSQL passwords. Note that changing
`POSTGRES_PASSWORD` in `.env` alone does not re-password an existing
database: Postgres only applies it when the `postgres_data` volume is first
initialized. To actually rotate it on an existing deployment, either
`ALTER ROLE keycloak WITH PASSWORD ...` inside the running database or
recreate the `postgres_data` volume.
## CORS
The wildcard `Access-Control-Allow-Origin: *` defaults were removed from
`config/nginx.conf`. The viewer is served by the same nginx as the DICOMweb
proxy, so same-origin deployments need no CORS headers. If you host the viewer
on a different origin, use the commented explicit-origin example in the nginx
config - never `*` on an authenticated endpoint that serves PHI.

View File

@ -50,6 +50,15 @@ http {
gzip_comp_level 9; gzip_comp_level 9;
etag on; etag on;
# Content-Security-Policy: test in Report-Only mode first and watch
# the browser console for violations before switching the header name
# to the enforcing Content-Security-Policy. Note: nginx add_header does
# not merge into a location that sets its own add_header - if you
# promote this to an active header, re-declare it inside every location
# block that already uses add_header (several proxy locations here do)
# or it will silently not apply there.
# add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https: blob:; worker-src 'self' blob:; object-src 'self' blob:; frame-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
location /sw.js { location /sw.js {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
proxy_cache_bypass $http_pragma; proxy_cache_bypass $http_pragma;
@ -105,19 +114,25 @@ http {
expires 0; expires 0;
add_header Cache-Control private; add_header Cache-Control private;
add_header 'Access-Control-Allow-Origin' '*' always; # CORS: this recipe serves the viewer from this same nginx, so
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; # browser requests to this endpoint are same-origin and need NO
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always; # CORS headers at all. If you host the viewer on a different
# origin, allow that origin explicitly and only that origin -
if ($request_method = OPTIONS) { # never use '*' on an authenticated endpoint that serves PHI.
add_header 'Access-Control-Allow-Origin' '*'; # Cross-origin example (response headers plus OPTIONS preflight):
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; # add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept'; # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Max-Age' 1728000; # add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always;
add_header 'Content-Type' 'text/plain; charset=utf-8'; #
add_header 'Content-Length' 0; # if ($request_method = OPTIONS) {
return 204; # add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com';
} # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept';
# add_header 'Access-Control-Max-Age' 1728000;
# add_header 'Content-Type' 'text/plain; charset=utf-8';
# add_header 'Content-Length' 0;
# return 204;
# }
proxy_pass http://orthanc:8042/; proxy_pass http://orthanc:8042/;
} }
@ -137,19 +152,25 @@ http {
expires 0; expires 0;
add_header Cache-Control private; add_header Cache-Control private;
add_header 'Access-Control-Allow-Origin' '*' always; # CORS: this recipe serves the viewer from this same nginx, so
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; # browser requests to this endpoint are same-origin and need NO
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always; # CORS headers at all. If you host the viewer on a different
# origin, allow that origin explicitly and only that origin -
if ($request_method = OPTIONS) { # never use '*' on an authenticated endpoint that serves PHI.
add_header 'Access-Control-Allow-Origin' '*'; # Cross-origin example (response headers plus OPTIONS preflight):
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; # add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept'; # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Max-Age' 1728000; # add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept' always;
add_header 'Content-Type' 'text/plain; charset=utf-8'; #
add_header 'Content-Length' 0; # if ($request_method = OPTIONS) {
return 204; # add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com';
} # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# add_header 'Access-Control-Allow-Headers' 'Authorization, Origin, X-Requested-With, Content-Type, Accept';
# add_header 'Access-Control-Max-Age' 1728000;
# add_header 'Content-Type' 'text/plain; charset=utf-8';
# add_header 'Content-Length' 0;
# return 204;
# }
proxy_pass http://orthanc:8042/dicom-web/; proxy_pass http://orthanc:8042/dicom-web/;
} }

View File

@ -4,7 +4,7 @@ email_domains=["*"]
cookie_secure="false" cookie_secure="false"
cookie_expire="9m30s" cookie_expire="9m30s"
cookie_refresh="5m" cookie_refresh="5m"
client_secret="2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg" client_secret="REPLACE_WITH_A_GENERATED_CLIENT_SECRET"
client_id="ohif_viewer" client_id="ohif_viewer"
redirect_url="http://YOUR_DOMAIN/oauth2/callback" redirect_url="http://YOUR_DOMAIN/oauth2/callback"

View File

@ -686,7 +686,7 @@
"enabled": true, "enabled": true,
"alwaysDisplayInConsole": false, "alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret", "clientAuthenticatorType": "client-secret",
"secret": "2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg", "secret": "REPLACE_WITH_A_GENERATED_CLIENT_SECRET",
"redirectUris": [ "redirectUris": [
"http://127.0.0.1/oauth2/callback" "http://127.0.0.1/oauth2/callback"
], ],

View File

@ -51,14 +51,17 @@ services:
KC_DB_URL: 'jdbc:postgresql://postgres:5432/keycloak' KC_DB_URL: 'jdbc:postgresql://postgres:5432/keycloak'
KC_DB_SCHEMA: public KC_DB_SCHEMA: public
KC_DB_USERNAME: keycloak KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: password # Keycloak authenticates to Postgres as the keycloak role provisioned
# below, so this must be the same value as POSTGRES_PASSWORD. Sourced
# from the single POSTGRES_PASSWORD variable to keep them in lockstep.
KC_DB_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in your .env}
KC_HOSTNAME_ADMIN_URL: http://YOUR_DOMAIN/keycloak/ KC_HOSTNAME_ADMIN_URL: http://YOUR_DOMAIN/keycloak/
KC_HOSTNAME_URL: http://YOUR_DOMAIN/keycloak/ KC_HOSTNAME_URL: http://YOUR_DOMAIN/keycloak/
KC_HOSTNAME_STRICT_BACKCHANNEL: true KC_HOSTNAME_STRICT_BACKCHANNEL: true
KC_HOSTNAME_STRICT_HTTPS: false KC_HOSTNAME_STRICT_HTTPS: false
KC_HTTP_ENABLED: true KC_HTTP_ENABLED: true
KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD in your .env}
KC_HEALTH_ENABLED: true KC_HEALTH_ENABLED: true
KC_METRICS_ENABLED: true KC_METRICS_ENABLED: true
KC_PROXY: edge KC_PROXY: edge
@ -104,7 +107,7 @@ services:
environment: environment:
POSTGRES_DB: keycloak POSTGRES_DB: keycloak
POSTGRES_USER: keycloak POSTGRES_USER: keycloak
POSTGRES_PASSWORD: password POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in your .env}
restart: unless-stopped restart: unless-stopped
networks: networks:
- default - default

View File

@ -24,3 +24,12 @@ localhost/pacs -> Orthanc
See [here](../../../docs/docs/deployment/nginx--image-archive.md) for more information about this recipe. See [here](../../../docs/docs/deployment/nginx--image-archive.md) for more information about this recipe.
# Security notes
- CORS: earlier versions of this recipe set `Access-Control-Allow-Origin: *`
on the `/pacs/` proxy. That default has been removed. The viewer is served
by the same nginx as the proxy, so same-origin deployments need no CORS
headers. If you host the viewer on a different origin, set that origin
explicitly in `config/nginx.conf` - never `*` on an endpoint that serves
PHI.

View File

@ -32,6 +32,15 @@ http {
gzip_comp_level 9; gzip_comp_level 9;
etag on; etag on;
# Content-Security-Policy: test in Report-Only mode first and watch the
# browser console for violations before switching the header name to the
# enforcing Content-Security-Policy. Note: nginx add_header does not merge
# into a location that sets its own add_header - if you promote this to an
# active header, re-declare it inside every location block that already
# uses add_header (the /pacs/ proxy here does) or it will silently not
# apply there.
# add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https: blob:; worker-src 'self' blob:; object-src 'self' blob:; frame-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
# Reverse Proxy for `orthanc` APIs (including DICOMWeb) # Reverse Proxy for `orthanc` APIs (including DICOMWeb)
# #
@ -47,17 +56,14 @@ http {
expires 0; expires 0;
add_header Cache-Control private; add_header Cache-Control private;
# Add CORS headers # CORS: this recipe serves the viewer from this same nginx, so browser
# Note: uncomment the following line to allow all domains to access the Orthanc APIs # requests to /pacs/ are same-origin and need NO CORS header at all.
# You should actually only allow the domains you trust to access the APIs # If you host the viewer on a different origin, allow that origin
# add_header 'Access-Control-Allow-Origin' '*' always; # explicitly and only that origin - never use '*' on an endpoint that
# serves PHI. Example:
# add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com' always;
proxy_pass http://orthanc:8042/; proxy_pass http://orthanc:8042/;
# By default, this endpoint is protected by CORS (cross-origin-resource-sharing)
# You can add headers to allow other domains to request this resource.
# See the "Updating CORS Settings" example below
add_header 'Access-Control-Allow-Origin' '*' always;
} }

1
platform/app/commit.txt Normal file
View File

@ -0,0 +1 @@
2800f82e3cd02d991146af3f2887fca18c4ab161

View File

@ -1,6 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<!--
Content-Security-Policy is delivered via hosting headers (netlify.toml
for Netlify deploys; commented add_header examples in the nginx
recipes under platform/app/.recipes), not as a meta tag: a meta tag
cannot express Report-Only mode. See the deployment docs
(docs/deployment) for the policy and the criteria for promoting it
from Report-Only to enforcing.
-->
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"

View File

@ -5,6 +5,7 @@ import CallbackPage from '../routes/CallbackPage';
import SignoutCallbackComponent from '../routes/SignoutCallbackComponent'; import SignoutCallbackComponent from '../routes/SignoutCallbackComponent';
import LegacyClient from './legacyOIDCClient'; import LegacyClient from './legacyOIDCClient';
import NextClient from './nextOIDCClient'; import NextClient from './nextOIDCClient';
import { sanitizeSameOriginRedirect } from './sanitizeRedirect';
function _isAbsoluteUrl(url) { function _isAbsoluteUrl(url) {
return url.includes('http://') || url.includes('https://'); return url.includes('http://') || url.includes('https://');
@ -55,7 +56,10 @@ function LogoutComponent(props) {
const location = useLocation(); const location = useLocation();
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
userManager.signoutRedirect({ userManager.signoutRedirect({
post_logout_redirect_uri: query.get('redirect_uri'), post_logout_redirect_uri: sanitizeSameOriginRedirect(
query.get('redirect_uri'),
window.location.origin
),
}); });
return null; return null;
} }

View File

@ -0,0 +1,40 @@
import { sanitizeSameOriginRedirect } from './sanitizeRedirect';
describe('sanitizeSameOriginRedirect', () => {
const origin = 'https://viewer.example.com';
it('returns the same href back for an absolute same-origin URL', () => {
expect(sanitizeSameOriginRedirect('https://viewer.example.com/worklist?a=1', origin)).toBe(
'https://viewer.example.com/worklist?a=1'
);
});
it('resolves relative paths against the app origin', () => {
expect(sanitizeSameOriginRedirect('/worklist', origin)).toBe(
'https://viewer.example.com/worklist'
);
});
it('rejects an absolute cross-origin URL', () => {
expect(sanitizeSameOriginRedirect('https://evil.example.com/', origin)).toBeUndefined();
});
it('rejects a protocol-relative cross-origin URL', () => {
expect(sanitizeSameOriginRedirect('//evil.example.com/x', origin)).toBeUndefined();
});
it('rejects javascript: scheme values', () => {
expect(sanitizeSameOriginRedirect('javascript:alert(1)', origin)).toBeUndefined();
});
it('rejects null and empty values', () => {
expect(sanitizeSameOriginRedirect(null, origin)).toBeUndefined();
expect(sanitizeSameOriginRedirect('', origin)).toBeUndefined();
});
it('rejects lookalike hosts that merely start with the app host', () => {
expect(
sanitizeSameOriginRedirect('https://viewer.example.com.evil.com/', origin)
).toBeUndefined();
});
});

View File

@ -0,0 +1,22 @@
/**
* Returns the given redirect target only when it resolves to the provided
* origin; otherwise returns undefined so callers fall back to the configured
* post-logout destination.
*/
export function sanitizeSameOriginRedirect(
value: string | null,
origin: string
): string | undefined {
if (!value) {
return undefined;
}
try {
const url = new URL(value, origin);
if (url.origin !== origin) {
return undefined;
}
return url.href;
} catch {
return undefined;
}
}

1
platform/app/version.txt Normal file
View File

@ -0,0 +1 @@
3.13.0-beta.110-new-one-click

View File

@ -176,3 +176,33 @@ accurate documentation, we will link to each provider's own recommended steps:
- [Add SSL Support](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-https-custom-domain-cdn) - [Add SSL Support](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-https-custom-domain-cdn)
- [Configure a Custom Domain](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-custom-domain-name) - [Configure a Custom Domain](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-custom-domain-name)
## Content-Security-Policy
The hosted viewer ships a `Content-Security-Policy-Report-Only` header (see
`netlify.toml`; the nginx recipes under `platform/app/.recipes` carry the same
policy as commented `add_header` examples). Report-Only means the browser
evaluates the policy and logs violations to the console, but blocks nothing.
What the policy covers:
- `default-src 'self'` with `frame-ancestors 'none'` (mirrors the existing
`X-Frame-Options: DENY`), `base-uri 'self'`, and `form-action 'self'`.
- `script-src` allows `'unsafe-inline'`/`'unsafe-eval'` (required by the
current bundles and the cornerstone/vtk WASM and worker paths) plus
`https://cdnjs.cloudflare.com` for the Rollbar snippet used by demo builds.
- `worker-src`, `object-src`, `frame-src`, and `img-src` allow `blob:` because
web workers, DICOM PDF rendering, and image data all use blob URLs.
- `connect-src` allows any `https:` origin because deployments point the
viewer at arbitrary DICOMweb servers; narrow this to your archive's origin
in your own deployment if you can.
Reading violations: open the browser devtools console and filter for
`Content-Security-Policy`. Report-only violations are logged as warnings and
do not affect behavior; each one is either telemetry for tightening the policy
or a directive that must stay relaxed.
Criteria for promoting Report-Only to an enforcing `Content-Security-Policy`
header: zero unexplained violations across the e2e suite and a manual session
that exercises study load, MPR, SEG display, PDF display, and video display.
Until then, keep the header name `Content-Security-Policy-Report-Only`.

View File

@ -0,0 +1,104 @@
---
sidebar_position: 9
sidebar_label: Deployment recipes
title: Deployment recipes migration
---
# Deployment recipes migration
The deployment recipes under `platform/app/.recipes` no longer ship working
credential values or permissive CORS defaults. If you deploy from these
recipes, or you templated your own deployment from an earlier checkout, a few
one-time steps are now required. The viewer application itself is unaffected.
## Keycloak recipes require a .env file
Applies to `Nginx-Orthanc-Keycloak` and `Nginx-Dcm4chee-Keycloak`.
The docker-compose files previously hardcoded the Keycloak admin and
PostgreSQL passwords. They now read them from the environment and refuse to
start while any is unset:
```text
error while interpolating services.keycloak.environment.POSTGRES_PASSWORD:
required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in your .env
```
**Migration:** copy `.env.example` to `.env` next to `docker-compose.yml` and
set strong values:
```bash
cp .env.example .env
# then edit .env and fill in:
# POSTGRES_PASSWORD=
# KEYCLOAK_ADMIN_PASSWORD=
```
`POSTGRES_PASSWORD` is the single PostgreSQL credential - Keycloak connects to
Postgres as the `keycloak` role provisioned with it, so `KC_DB_PASSWORD` is
derived from `POSTGRES_PASSWORD` in the compose file rather than set
separately.
## Keycloak client secret is now a placeholder
The realm import (`config/ohif-keycloak-realm.json`) and the oauth2-proxy
config (`config/oauth2-proxy.cfg`) previously shipped a fixed value for the
`ohif_viewer` client secret. Both files now contain the placeholder
`REPLACE_WITH_A_GENERATED_CLIENT_SECRET`.
**Migration:** generate a fresh value and put the same value in both files
before `docker compose up`. To generate one after the realm is imported:
Keycloak admin console -> Clients -> `ohif_viewer` -> Credentials ->
Regenerate.
**If you deployed from an earlier checkout:** the old fixed value is public in
the repository history, so regenerating it is required for existing
deployments too, not just new ones. Regenerate it in the Keycloak admin
console and update `config/oauth2-proxy.cfg` to match. Rotating the Keycloak
admin and PostgreSQL passwords is likewise recommended. See the
`SECURITY-NOTES.md` file inside each Keycloak recipe directory for the full
checklist.
## nginx recipes no longer send wildcard CORS headers
Applies to `Nginx-Orthanc`, `Nginx-Orthanc-Keycloak`, and
`Nginx-Dcm4chee-Keycloak`.
The nginx configs previously answered PACS/DICOMweb proxy requests with
`Access-Control-Allow-Origin: *`. Those headers were removed.
- **Same-origin deployments (the recipe default):** no action needed. The
viewer is served by the same nginx as the proxy, so these requests never
needed CORS headers.
- **Cross-origin deployments** (viewer hosted on a different origin than the
proxy): browser requests to the proxy will now fail CORS checks until you
re-add the headers with your viewer's origin spelled out explicitly. Each
config contains a commented example next to the old location:
```nginx
# add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com' always;
```
Use your actual viewer origin; avoid `'*'` on endpoints that serve patient
data.
## Logout redirect_uri is validated
`/logout?redirect_uri=...` now only honors same-origin (or relative) values.
Anything else falls back to the configured `post_logout_redirect_uri` from
your OIDC configuration. The viewer's own logout flows always pass same-origin
values, so in-app behavior is unchanged.
**Migration:** only needed if you linked to `/logout` with a `redirect_uri`
pointing at a different origin (for example, an external portal). Configure
that destination as the client's `post_logout_redirect_uri` in your OIDC
configuration instead of passing it in the query string.
## New Content-Security-Policy-Report-Only header (no action needed)
Hosted deployments configured via `netlify.toml` now send a
`Content-Security-Policy-Report-Only` header. It is observational only:
browsers log would-be violations to the devtools console and block nothing.
The nginx recipes carry the same policy as commented examples. See the
[Deploy Static Assets](../../deployment/static-assets.md#content-security-policy)
docs for what the policy covers and how to read the console reports.

View File

@ -26,5 +26,9 @@ The largest changes in 3.13 are infrastructure-level:
- **[DICOM video viewport](./dicom-video.md)** — the - **[DICOM video viewport](./dicom-video.md)** — the
`@ohif/extension-dicom-video.viewportModule.dicom-video` namespace was `@ohif/extension-dicom-video.viewportModule.dicom-video` namespace was
removed; route DICOM video display sets through the Cornerstone viewport. removed; route DICOM video display sets through the Cornerstone viewport.
- **[Deployment recipes](./deployment-recipes.md)** — the Keycloak recipes
now require a `.env` file and a generated client secret before
`docker compose up`, and the nginx recipes no longer send wildcard CORS
headers (cross-origin deployments must set an explicit origin).
<DocCardList /> <DocCardList />

@ -1 +1 @@
Subproject commit 3f85fe843c9a1ffaccb826845f317dace9c06c45 Subproject commit bd743ed76b403e6d476e1af0bbfe7479d96b563d

View File

@ -203,8 +203,12 @@ test('should display SCOORD3D probe measurements correctly', async ({
} }
}); });
// Wait for rendering to complete before taking screenshot // Wait for the zoom re-render to actually settle before capturing. A fixed
await page.waitForTimeout(2000); // delay can capture a partially rendered viewport on a slow/loaded runner,
// which shows up as a near-total screenshot diff.
await waitForViewportsRendered(page);
await waitForPaintToSettle(page);
await page.waitForTimeout(3000);
const activeViewport = await viewportPageObject.active; const activeViewport = await viewportPageObject.active;