fix: recipe config cleanup, report-only CSP, logout redirect validation (#6124)
This commit is contained in:
parent
973631b7e8
commit
f8546ce0e0
1
.github/workflows/playwright.yml
vendored
1
.github/workflows/playwright.yml
vendored
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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=
|
||||||
@ -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.
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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=
|
||||||
@ -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.
|
||||||
@ -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/;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
1
platform/app/commit.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
2800f82e3cd02d991146af3f2887fca18c4ab161
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
40
platform/app/src/utils/sanitizeRedirect.test.ts
Normal file
40
platform/app/src/utils/sanitizeRedirect.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
22
platform/app/src/utils/sanitizeRedirect.ts
Normal file
22
platform/app/src/utils/sanitizeRedirect.ts
Normal 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
1
platform/app/version.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
3.13.0-beta.110-new-one-click
|
||||||
@ -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`.
|
||||||
|
|||||||
@ -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.
|
||||||
@ -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 />
|
||||||
|
|||||||
2
testdata
2
testdata
@ -1 +1 @@
|
|||||||
Subproject commit 3f85fe843c9a1ffaccb826845f317dace9c06c45
|
Subproject commit bd743ed76b403e6d476e1af0bbfe7479d96b563d
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user