diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 3395b6495..c5787466a 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -23,6 +23,7 @@ concurrency: jobs: playwright-tests: timeout-minutes: 120 + environment: fork-pr-approval runs-on: [self-hosted, nashua] strategy: fail-fast: false diff --git a/netlify.toml b/netlify.toml index c577d740f..da570cbfd 100644 --- a/netlify.toml +++ b/netlify.toml @@ -24,6 +24,14 @@ X-Frame-Options = "DENY" 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 = ''' max-age=0, no-cache, diff --git a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/.env.example b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/.env.example new file mode 100644 index 000000000..5ca0f8304 --- /dev/null +++ b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/.env.example @@ -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= diff --git a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/SECURITY-NOTES.md b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/SECURITY-NOTES.md new file mode 100644 index 000000000..889608e6b --- /dev/null +++ b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/SECURITY-NOTES.md @@ -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. diff --git a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/nginx.conf b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/nginx.conf index 79043d928..92881c849 100644 --- a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/nginx.conf +++ b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/nginx.conf @@ -54,6 +54,15 @@ http { gzip_comp_level 9; 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 { add_header Cache-Control "no-cache"; proxy_cache_bypass $http_pragma; @@ -101,19 +110,25 @@ http { expires 0; add_header Cache-Control private; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; - 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; - } + # CORS: this recipe serves the viewer from this same nginx, so + # browser requests to this endpoint are same-origin and need NO + # CORS headers at all. If you host the viewer on a different + # origin, allow that origin explicitly and only that origin - + # never use '*' on an authenticated endpoint that serves PHI. + # Cross-origin example (response headers plus OPTIONS preflight): + # add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com' always; + # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + # 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' '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; proxy_pass http://arc:8080; @@ -155,19 +170,8 @@ http { expires 0; add_header Cache-Control private; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; - 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; - } + # CORS: same-origin deployment - no CORS headers needed here; see + # the note on the /pacs/ location above before adding any. proxy_pass http://arc:8080; } diff --git a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/oauth2-proxy.cfg b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/oauth2-proxy.cfg index 985525af9..cf8caddcb 100644 --- a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/oauth2-proxy.cfg +++ b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/oauth2-proxy.cfg @@ -4,7 +4,7 @@ email_domains=["*"] cookie_secure="false" cookie_expire="9m30s" cookie_refresh="5m" -client_secret="2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg" +client_secret="REPLACE_WITH_A_GENERATED_CLIENT_SECRET" client_id="ohif_viewer" redirect_url="http://YOUR_DOMAIN/oauth2/callback" diff --git a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/ohif-keycloak-realm.json b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/ohif-keycloak-realm.json index 3064ee70e..8b79a75d2 100644 --- a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/ohif-keycloak-realm.json +++ b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/config/ohif-keycloak-realm.json @@ -686,7 +686,7 @@ "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "secret": "2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg", + "secret": "REPLACE_WITH_A_GENERATED_CLIENT_SECRET", "redirectUris": [ "http://127.0.0.1/oauth2/callback" ], diff --git a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/docker-compose.yml b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/docker-compose.yml index 1c3aa0ec2..71a0bb908 100644 --- a/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/docker-compose.yml +++ b/platform/app/.recipes/Nginx-Dcm4chee-Keycloak/docker-compose.yml @@ -94,14 +94,17 @@ services: KC_DB_URL: 'jdbc:postgresql://postgres:5432/keycloak' KC_DB_SCHEMA: public 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_URL: http://YOUR_DOMAIN/keycloak/ KC_HOSTNAME_STRICT_BACKCHANNEL: true KC_HOSTNAME_STRICT_HTTPS: false KC_HTTP_ENABLED: true 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_METRICS_ENABLED: true KC_PROXY: edge @@ -139,7 +142,7 @@ services: environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak - POSTGRES_PASSWORD: password + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in your .env} restart: unless-stopped networks: - default diff --git a/platform/app/.recipes/Nginx-Orthanc-Keycloak/.env.example b/platform/app/.recipes/Nginx-Orthanc-Keycloak/.env.example new file mode 100644 index 000000000..5ca0f8304 --- /dev/null +++ b/platform/app/.recipes/Nginx-Orthanc-Keycloak/.env.example @@ -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= diff --git a/platform/app/.recipes/Nginx-Orthanc-Keycloak/SECURITY-NOTES.md b/platform/app/.recipes/Nginx-Orthanc-Keycloak/SECURITY-NOTES.md new file mode 100644 index 000000000..2852869ee --- /dev/null +++ b/platform/app/.recipes/Nginx-Orthanc-Keycloak/SECURITY-NOTES.md @@ -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. diff --git a/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/nginx.conf b/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/nginx.conf index 272a478a3..7a7e01d00 100644 --- a/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/nginx.conf +++ b/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/nginx.conf @@ -50,6 +50,15 @@ http { gzip_comp_level 9; 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 { add_header Cache-Control "no-cache"; proxy_cache_bypass $http_pragma; @@ -105,19 +114,25 @@ http { expires 0; add_header Cache-Control private; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; - 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; - } + # CORS: this recipe serves the viewer from this same nginx, so + # browser requests to this endpoint are same-origin and need NO + # CORS headers at all. If you host the viewer on a different + # origin, allow that origin explicitly and only that origin - + # never use '*' on an authenticated endpoint that serves PHI. + # Cross-origin example (response headers plus OPTIONS preflight): + # add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com' always; + # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + # 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' '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/; } @@ -137,19 +152,25 @@ http { expires 0; add_header Cache-Control private; - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; - 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; - } + # CORS: this recipe serves the viewer from this same nginx, so + # browser requests to this endpoint are same-origin and need NO + # CORS headers at all. If you host the viewer on a different + # origin, allow that origin explicitly and only that origin - + # never use '*' on an authenticated endpoint that serves PHI. + # Cross-origin example (response headers plus OPTIONS preflight): + # add_header 'Access-Control-Allow-Origin' 'https://viewer.example.com' always; + # add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + # 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' '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/; } diff --git a/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/oauth2-proxy.cfg b/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/oauth2-proxy.cfg index 985525af9..cf8caddcb 100644 --- a/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/oauth2-proxy.cfg +++ b/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/oauth2-proxy.cfg @@ -4,7 +4,7 @@ email_domains=["*"] cookie_secure="false" cookie_expire="9m30s" cookie_refresh="5m" -client_secret="2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg" +client_secret="REPLACE_WITH_A_GENERATED_CLIENT_SECRET" client_id="ohif_viewer" redirect_url="http://YOUR_DOMAIN/oauth2/callback" diff --git a/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/ohif-keycloak-realm.json b/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/ohif-keycloak-realm.json index 3064ee70e..8b79a75d2 100644 --- a/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/ohif-keycloak-realm.json +++ b/platform/app/.recipes/Nginx-Orthanc-Keycloak/config/ohif-keycloak-realm.json @@ -686,7 +686,7 @@ "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", - "secret": "2Xtlde7aozdkzzYHdIxQNfPDr0wNPTgg", + "secret": "REPLACE_WITH_A_GENERATED_CLIENT_SECRET", "redirectUris": [ "http://127.0.0.1/oauth2/callback" ], diff --git a/platform/app/.recipes/Nginx-Orthanc-Keycloak/docker-compose.yml b/platform/app/.recipes/Nginx-Orthanc-Keycloak/docker-compose.yml index 1a44c3013..a586a6c19 100644 --- a/platform/app/.recipes/Nginx-Orthanc-Keycloak/docker-compose.yml +++ b/platform/app/.recipes/Nginx-Orthanc-Keycloak/docker-compose.yml @@ -51,14 +51,17 @@ services: KC_DB_URL: 'jdbc:postgresql://postgres:5432/keycloak' KC_DB_SCHEMA: public 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_URL: http://YOUR_DOMAIN/keycloak/ KC_HOSTNAME_STRICT_BACKCHANNEL: true KC_HOSTNAME_STRICT_HTTPS: false KC_HTTP_ENABLED: true 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_METRICS_ENABLED: true KC_PROXY: edge @@ -104,7 +107,7 @@ services: environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak - POSTGRES_PASSWORD: password + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in your .env} restart: unless-stopped networks: - default diff --git a/platform/app/.recipes/Nginx-Orthanc/README.md b/platform/app/.recipes/Nginx-Orthanc/README.md index 14e1a70b8..af25b5b82 100644 --- a/platform/app/.recipes/Nginx-Orthanc/README.md +++ b/platform/app/.recipes/Nginx-Orthanc/README.md @@ -24,3 +24,12 @@ localhost/pacs -> Orthanc 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. diff --git a/platform/app/.recipes/Nginx-Orthanc/config/nginx.conf b/platform/app/.recipes/Nginx-Orthanc/config/nginx.conf index a81f9c9ed..86bafacc1 100644 --- a/platform/app/.recipes/Nginx-Orthanc/config/nginx.conf +++ b/platform/app/.recipes/Nginx-Orthanc/config/nginx.conf @@ -32,6 +32,15 @@ http { gzip_comp_level 9; 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) # @@ -47,17 +56,14 @@ http { expires 0; add_header Cache-Control private; - # Add CORS headers - # Note: uncomment the following line to allow all domains to access the Orthanc APIs - # You should actually only allow the domains you trust to access the APIs - # add_header 'Access-Control-Allow-Origin' '*' always; + # CORS: this recipe serves the viewer from this same nginx, so browser + # requests to /pacs/ are same-origin and need NO CORS header at all. + # If you host the viewer on a different origin, allow that origin + # 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/; - - # 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; } diff --git a/platform/app/commit.txt b/platform/app/commit.txt new file mode 100644 index 000000000..a5efcc2e1 --- /dev/null +++ b/platform/app/commit.txt @@ -0,0 +1 @@ +2800f82e3cd02d991146af3f2887fca18c4ab161 \ No newline at end of file diff --git a/platform/app/public/html-templates/index.html b/platform/app/public/html-templates/index.html index ed7679217..59777526b 100644 --- a/platform/app/public/html-templates/index.html +++ b/platform/app/public/html-templates/index.html @@ -1,6 +1,14 @@ + { + 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(); + }); +}); diff --git a/platform/app/src/utils/sanitizeRedirect.ts b/platform/app/src/utils/sanitizeRedirect.ts new file mode 100644 index 000000000..d2947962c --- /dev/null +++ b/platform/app/src/utils/sanitizeRedirect.ts @@ -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; + } +} diff --git a/platform/app/version.txt b/platform/app/version.txt new file mode 100644 index 000000000..0263844b9 --- /dev/null +++ b/platform/app/version.txt @@ -0,0 +1 @@ +3.13.0-beta.110-new-one-click \ No newline at end of file diff --git a/platform/docs/docs/deployment/static-assets.md b/platform/docs/docs/deployment/static-assets.md index d04d7af0f..dde927c16 100644 --- a/platform/docs/docs/deployment/static-assets.md +++ b/platform/docs/docs/deployment/static-assets.md @@ -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) - [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`. diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/deployment-recipes.md b/platform/docs/docs/migration-guide/3p12-to-3p13/deployment-recipes.md new file mode 100644 index 000000000..d6611ae07 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/deployment-recipes.md @@ -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. diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/index.md b/platform/docs/docs/migration-guide/3p12-to-3p13/index.md index 5e862d75c..e86ebfd99 100644 --- a/platform/docs/docs/migration-guide/3p12-to-3p13/index.md +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/index.md @@ -26,5 +26,9 @@ The largest changes in 3.13 are infrastructure-level: - **[DICOM video viewport](./dicom-video.md)** — the `@ohif/extension-dicom-video.viewportModule.dicom-video` namespace was 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). diff --git a/testdata b/testdata index 3f85fe843..bd743ed76 160000 --- a/testdata +++ b/testdata @@ -1 +1 @@ -Subproject commit 3f85fe843c9a1ffaccb826845f317dace9c06c45 +Subproject commit bd743ed76b403e6d476e1af0bbfe7479d96b563d diff --git a/tests/Scoord3dProbe.spec.ts b/tests/Scoord3dProbe.spec.ts index c03f0e012..7db9670e9 100644 --- a/tests/Scoord3dProbe.spec.ts +++ b/tests/Scoord3dProbe.spec.ts @@ -203,8 +203,12 @@ test('should display SCOORD3D probe measurements correctly', async ({ } }); - // Wait for rendering to complete before taking screenshot - await page.waitForTimeout(2000); + // Wait for the zoom re-render to actually settle before capturing. A fixed + // 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;