Skip to content

Security design

This page describes the implemented authentication and authorisation model, the enforcement points, and the security headers per routing mode. For user steps, see end-user: managing access.

Container isolation, custom application boundaries, and cache data protection are described in site application architecture, custom applications, and data storage and recovery.

Trust model

Sites is an internal service for an organisation that operates it in a trusted environment. It is not an open hosting service where anonymous people create sites. The security design depends on these trust levels:

Actor Trust Reason
Platform administrators (hub.administrator_users, hub.administrator_groups) Trusted They operate the platform and have full control.
Staff (hub.staff_users, hub.staff_groups) Trusted Only staff can create sites: POST /hub/api/v2/sites/ requires the staff policy (staff_oidc_api_auth in hub/.../api/v2/views.py).
Site owners and site administrators Trusted, and responsible for their site A site administrator can publish any HTML and JavaScript, and can configure a custom application. The owner and the administrators can give administrator rights to any user or group of the identity provider, also to people outside the organisation. The person who gives these rights is responsible for this decision.
Users with view access, token holders, holders of signed or session URLs Not trusted They get only the access that the site gives them.
Anonymous visitors Not trusted They get only public content, and read-only methods.

The platform enforces these boundaries:

  • A user without access to a site cannot read or change it.
  • A site cannot read or change the files, Secrets, or Kubernetes objects of another site. Between site Pods, this needs networkPolicy.enabled (enforcement surfaces).
  • A site cannot use the Hub session when routing.hubHost is set.

The platform accepts a risk that needs a site administrator who acts in bad faith, for example a script that a site administrator publishes on purpose. The accepted risks section lists these risks.

Enforcement surfaces

Authentication is enforced where traffic enters. The Go API performs no authentication at all: it binds only 127.0.0.1:10000, and no Service or Ingress routes to it. The proxy is its only client, reaching it over 127.0.0.1:10000 (proxy/nginx/api.conf). Other Pods cannot connect to the API. The liveness probe runs /api healthcheck in the container, because the kubelet cannot reach a loopback port. When networkPolicy.enabled is set, the site NetworkPolicy permits ingress only on port 4180 (OAuth2 Proxy), from the routing namespace, the monitoring and metrics namespaces, and same-namespace pods (helm/ecmwf-sites/templates/networkpolicy-site.yaml). All sites share one namespace, so this port limit also stops one site Pod from calling the unauthenticated ports (8080, 7000, 6000, 9000) of another site Pod.

Surface Enforced by What is accepted
Site content and listing UI (private site) site-oauth2 (OAuth2 Proxy) + Lua private_auth OpenID Connect (OIDC) browser session, or any chain credential (master token, Branca token, signed or session URL)
Site content and listing UI (public site) Lua public_auth + basic.conf Anonymous, or HTTP Basic where a .htpasswd protects the path
Site API /s/api/ Lua admin_auth Site API tokens (see chain below). JSON Web Token (JWT) sessions must carry a site-admin entitlement; master and Branca tokens are accepted without a role check (read-only Branca tokens are limited to read methods)
/s/api/v2/session-files/ Lua session_files_auth Read methods: site-admin or staff. Methods that change data: a session URL with write permission, a site token or master token with write permission, or a site administrator. Staff cannot change data here
/s/api/v2/pub/ (public sites) None (no Lua) Anonymous; the Go public router applies .htpasswd hiding
/s/signed, /s/session (private sites) Lua auth chain, then the Go API verifies the credential Signed/session URL parameters only
/s/admin/ Lua admin_auth Site administrators
/s/dav/ Lua (webdav_auth) Basic auth with a Site token as password (master token or Branca token), validated against the token system, not against .htpasswd file contents. The validated session lasts 1 hour: the token is kept in a Secure, HttpOnly, SameSite=Lax cookie scoped to the Site, and after expiry the proxy rotates the challenge realm nonce and forces a new prompt. On public sites, paths without a .htpasswd also allow anonymous read-only methods
/s/s3 Lua (s3_auth, x-amz-security-token header) Issued S3 credentials
/<space>/<site>/s/healthz NGINX allow/deny IPv4 loopback (allow 127.0.0.1, the Site API probe) always, plus trusted_ips matched against the direct peer address; everything else is denied
/s/metrics NGINX allow/deny trusted_ips only, evaluated against the real IP taken from the leftmost X-Forwarded-For entry, trusted from any source (real_ip_recursive off, set_real_ip_from 0.0.0.0/0); an empty list denies all; the location renders only when metrics are enabled
Hub UI + Hub API Flask session or bearer token OIDC login (Authorization header only; bearer values in the query string or form are ignored). Unsafe requests with an authentication cookie — the Hub session cookie, the OIDC id-token cookie, or the access_token cookie — must present the cross-site request forgery (CSRF) token (X-CSRF-Token header or csrf_token form field)

The Lua auth chain tries, in order: session URL → signed URL → master token (hex-64) → JWT → Branca token (site/src/ecmwf/sites/website/applications/proxy/lua/auth_chain.lua). Token prefixes: Bearer, Branca, Master. A failed token decode is negatively cached for 150 s; successful Branca decodes are cached for at most 10 s. Failures return 403 with a JSON body for API, _m2m, and S3 paths. A session or signed URL is accepted only on the content locations and on /s/api/v2/session-files/; the Site API /s/api/, the admin application /s/admin/ and /s/s3 run the chain without the two URL handlers (admin_auth.lua, s3_auth.lua), so a URL credential cannot reach them.

flowchart LR
    R["Request to site"] --> C1{"session URL?"}
    C1 -- yes --> OK["allow"]
    C1 -- no --> C2{"signed URL valid?"}
    C2 -- yes --> OK
    C2 -- no --> C3{"master token (hex64)?"}
    C3 -- yes --> OK
    C3 -- no --> C4{"JWT (OAuth2 session)?"}
    C4 -- yes --> OK
    C4 -- no --> C5{"Branca token valid + allowed?"}
    C5 -- yes --> OK
    C5 -- no --> N["403 (JSON for API paths)"]

Credential types

Credential Created by Default lifetime Scope
Site master token Hub at site creation (stored in spec.configuration.share.master_token and the per-site Secret) until rotated Full access to one site on every surface
HTTP access token (Branca) Site API POST /s/api/v2/token/ or Hub UI 90 days (site.token_default_duration); the Hub token-history view flags tokens expiring within jobs.http_access_token_expiration_warning_seconds (604800) — no notification job exists Configured permissions on one site
Signed URL POST /s/api/v2/signed-urls/ 7 days (site.signed_url_default_duration) One path and method set
Session URL POST /s/api/v2/session-urls/ 7 days (site.session_url_default_duration) Time-boxed browsing session
OAuth2 Proxy session cookie OAuth2 Proxy login 12 h expiry, 2 h refresh (oidc.cookie_expire/cookie_refresh), per-request CSRF cookie (5 m) Browsing one private site
Hub session cookie Hub login Browser session cookie: the configured session.permanent_lifetime (72 h) applies only to permanent sessions, and the Hub never sets session.permanent Hub UI
Hub OIDC id-token cookie OIDC login (flask-oidc); also read from an access_token cookie the cookie exp claim; the handler accepts it only when it carries a claims object and the expiry is in the future Hub UI and Hub API
Hub bearer token Identity provider via Hub API provider-dependent Hub API
Hub platform master token Hub administrator page (writes HUB_MASTER_TOKEN in the sites-secrets Secret; hub.master_token validates it) until regenerated Hub API as the api-administration-token identity, carrying the configured administrator and staff group policies

Treat the master token as a break-glass credential: store it in secret managers, rotate after exposure, and prefer short-lived scoped tokens for automation.

Roles: allowed users/groups may view a private site; staff (default_view_groups, default_view_users) may read site content and use the read methods of session-files, but cannot change files; administrators manage the site and use the full Site API. The Hub enforces these from the Site resource on every request. Both staff lists are empty by default, so an operator must set them explicitly.

.htpasswd protection

A file named .htpasswd protects its directory and everything below it. Two independent mechanisms enforce this:

  1. Go API (public router only). baseOrAncestorProtected() makes any request under a protected tree return 404 — not 401/403 — so path names are not disclosed. Listings prune protected entries, including cached continuation pages, and no Repr-Digest is returned for hidden paths (site/.../api/api/public_files.go, file_utils.go). The check also covers the resolved physical path, so a symlink cannot expose a protected file through a different name.
  2. Lua proxy (browser traffic on public sites). An NGINX map resolves each URI to the applicable .htpasswd — including /s/dav/ paths — and auth_basic requests credentials against that file (proxy/nginx/basic.conf, nginx.conf). auth_basic is rendered only for public sites. WebDAV never uses the file check: the /s/dav/ location runs only proxy/lua/basic.lua, which ignores the username and validates the password against the token system (64-hex master token or Branca token). Accounts listed in a .htpasswd are therefore not accepted on WebDAV; the request keeps getting 401 challenges until it presents a valid token (Site tokens have full access). The .htpasswd still gates WebDAV indirectly: on public sites the Lua anonymous fallback applies only when the map finds no .htpasswd for the path and the method is read-only.

On private sites .htpasswd is inert: auth_basic is not rendered and the Go public router is not registered.

The download-folder shortcut in the listing UI follows these rules: it applies the site type's access module, and on public sites it goes through the Go public router, so protected trees answer 404 (proxy/nginx/api.conf; see 06-site-proxy).

Because hiding returns 404, a missing file and a hidden file are indistinguishable on public endpoints. The authenticated API sees everything.

On public sites, the content locations run lua/public_auth.lua: anonymous requests outside GET, HEAD, OPTIONS, PROPFIND, SEARCH are rejected with 403, so anonymous write requests are never accepted. The Go public router behind /s/api/v2/pub/ registers only GET, HEAD, and OPTIONS; requests with other methods answer 404 there, because the router has no method-not-allowed handling.

Why public sites are read-only

The method restriction is a policy for the site, not a judgement about one application. The platform applies it to the whole site path, which serves both the static content and the custom application, and it decides on the HTTP method, the only signal HTTP gives for "this request may change state". The platform cannot inspect an application to decide that one POST is safe.

Remove the restriction, and every public site with a custom application becomes an anonymous, internet-facing write endpoint. These are the concrete reasons the platform does not allow that:

  • Open compute. The site proxy has no general rate limiter, the request body size is unlimited (client_max_body_size 0), and the request body streams to the application. A public POST endpoint that does work is free CPU and memory for any client. An attacker can exhaust the application resource tier, or hold many slow requests to exhaust the 1024 worker connections. Both degrade the whole site, including the read path.
  • Unaccountable access. An anonymous visitor has no identity. The platform cannot rate-limit per user, audit a person, or refuse anyone except by IP address.
  • Anonymous writes to shared storage. The application mounts the site volume at /www read-write. An application that persists a cache, an upload, or a database becomes anonymously writable up to the volume quota.
  • Server-side request forgery. An application that fetches a URL derived from the request body can be used as a proxy by anonymous clients.
  • Cross-site request forgery on basic-auth content. A public site can protect a tree with .htpasswd. A browser sends cached basic credentials on cross-site requests, and the site proxy has no CSRF check for anonymous requests, so an open write method is reachable from another origin.
  • Consistency. Anonymous WebDAV and the public Site API router serve reads only, and a signed URL with the r permission accepts read methods only. A public custom application would be the only anonymous write surface.
  • Unaudited application routes. The platform cannot see which routes an image exposes, including debug or administrative routes.

A public custom application therefore answers read-only methods only. When the application can put its input in the URL, use GET. When it needs a request body, run the site as a private site and give access to the users who need it.

Security headers per routing mode

When routing.hub_host is set, the Hub always adds the headers itself on the Hub host (in both modes), with frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN. The Hub session cookie is __Host-sites-hub, and every Hub API route answers CORS without credentials. Together with the separate host, a site script cannot use the Hub session. See 10 — Routing and the system administrator guide.

The platform-level headers come from different components per mode; the site proxy adds its own in both modes.

Header Ingress mode Gateway mode Site proxy
X-Content-Type-Options, Permissions-Policy, Referrer-Policy NGINX minion snippet Hub middleware —
Content-Security-Policy (frame-ancestors from trusted_domains, each domain plus its subdomains; 'self' is always included) minion snippet Hub middleware own Content Security Policy (CSP) in cors.conf (adds cors.extra_domains only when CORS is enabled)
HTTP Strict Transport Security (HSTS) minion snippet (routing.hsts) Hub middleware (routing.hsts) max-age=63072000, with X-Permitted-Cross-Domain-Policies: none, with CORS enabled or disabled
Cache-Control: no-cache minion/middleware middleware expires -1 per headers.no_cache
X-Robots-Tag: noindex — — when headers.no_robots
X-Sites-Proxy / X-Site-Proxy-Server minion/middleware middleware proxy identity header

In gateway mode the Hub middleware adds the security headers, Cache-Control, and the CSP with setdefault, so explicit application headers win. X-Sites-Proxy and, when routing.hsts is on, the HSTS header are assigned directly and always overwrite application values. The middleware is registered in gateway mode, and in ingress mode when routing.hub_host is set, because the NGINX snippets do not apply to the Hub host then (hub/src/ecmwf/sites/hub/routing_rules.py). The concrete gateway values: X-Sites-Proxy is the request host; the HSTS value is max-age=31536000; includeSubDomains; preload; the CSP is frame-ancestors 'self' https://<domain> https://*.<domain> … plus the fixed Hub sources HUB_CSP_SOURCES (the frame-ancestors list grows with trusted_domains; with an empty list only 'self' is allowed to frame):

default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none';
base-uri 'self'; form-action 'self';

The NGINX minion snippet of the Hub Ingress sends the same sources. The Hub loads its scripts, styles, and fonts only from its own origin (static/vendor), so a browser does not load code from a third-party CDN. The templates contain no inline script, no inline event handler, and no javascript: link, so scripts need no 'unsafe-inline': an injected script does not run. Page behaviour reads the data-* attributes of the page from the static bundles. Styles keep 'unsafe-inline' for the style blocks and style attributes of the templates. form-action 'self' keeps a form that an injection adds from posting to another origin. X-XSS-Protection and Expect-CT are gone: modern browsers ignore both, and script-src 'self' replaces the former. The site proxy CSP is a different policy and does not change.

trusted_ips (root config, default empty) restricts the site health location (/<space>/<site>/s/healthz, NGINX stub status) and the global /s/metrics endpoint, and is passed to OAuth2 Proxy. The health location always allows the IPv4 loopback address (127.0.0.1), so the Go API probe needs no configuration. There is no IP restriction on signed or session URLs.

Hub frontend packages

The Hub does not keep third-party frontend files in git. hub/frontend contains the npm manifest, the lockfile, and the allow-list vendor-files.json:

  • package.json sets an exact version for each package. package-lock.json records the registry.npmjs.org URL and the sha512 integrity of each package. npm ci stops when a download does not match.
  • The frontend stage of the Hub image (node image with a digest) runs npm ci --ignore-scripts, so no package install script runs, and then copy-vendor.mjs. The script copies only the allow-listed files to static/vendor and the licence files to /usr/share/licenses/sites-hub/. It stops when a version does not match the lockfile, when a path leaves its package, or when a file or licence is missing. The final Hub image does not contain Node.js or node_modules.
  • The Hub sends the static/vendor files unchanged. The response minifier (flask-minify) skips them, because a second minification can break upstream files (for example TomSelect and Swagger UI). This also keeps the licence headers.
  • hub/tests/test_vendor_assets.py fails when a template references an external script, style, or font, when a template uses a vendor file that is not in the allow-list, or when a package licence is not MIT, BSD-3-Clause, or Apache-2.0.
  • The Security workflow runs npm audit --package-lock-only on the lockfile (advisory, like the Python and Go audits). Dependabot proposes npm updates.

OIDC provider integration

The platform needs oidc.issuer_url, account_url (the account page, not the userinfo endpoint), and redirect_uri (the registered callback). Two modes exist; issuer_url is required in both:

  • Discovery (default): metadata is fetched from <issuer_url>/.well-known/openid-configuration. Single endpoint overrides replace discovered values.
  • Explicit endpoints: when all six of authorization_url, token_url, user_info_url, introspection_url, revocation_url, jwks_url are set, no discovery document is fetched. logout_url stays optional.

Keep client IDs and secrets in the oidc-secrets Secret, never in ConfigMaps or values files.

OAuth2 Proxy secrets

The per-site OAuth2 Proxy needs a cookie secret and the OIDC client secret. Neither value is in the files ConfigMap <id>-files, because a ConfigMap is not a Secret for Kubernetes role-based access control (RBAC), audit, and encryption at rest.

  • Cookie secret: the operator writes the Secret <id>-oauth2, key cookie-secret. The value is HMAC-SHA256(session.secret_key, "ecmwf-sites/oauth2-cookie-secret/v1/" + site_id), URL-safe base64 encoded; OAuth2 Proxy decodes it to a 32-byte AES key. Each site has a different key, and a site key does not disclose session.secret_key, which also protects the Hub session and the Redis payload signature. The site-oauth2 container reads it from OAUTH2_PROXY_COOKIE_SECRET.
  • OIDC client secret: the volume oidc-client-secret projects only the OIDC_CLIENT_SECRET key of oidc-secrets as a file. site-oauth2 mounts it read-only and reads it through clientSecretFile. The value must not end with a newline, because OAuth2 Proxy does not trim the file.

  • OIDC client values for the proxy: the site-proxy container gets only OIDC_CLIENT_ID and OIDC_CLIENT_SECRET, each as an environment variable with a secretKeyRef to one key of oidc-secrets. Its Lua code uses them for token introspection.

No site container gets the whole oidc-secrets Secret through envFrom. The site-api, site-admin, site-webdav, site-s3, site-mirror, site-initializer, and custom application containers get no value from oidc-secrets. The custom application container also has no site-config or oidc-client-secret mount (site/tests/test_oidc_secret_scope.py). A rotation of session.secret_key changes every site cookie secret at the next site update; users of private sites sign in again.

Discovery metadata service

core/src/ecmwf/sites/core/service/oidc_discovery.py is shared by the Hub and the operator. It fetches metadata only when an operation needs it.

flowchart TD
    A["need metadata"] --> B{"in process memory?"}
    B -- yes, unexpired --> U["use it"]
    B -- "expired, fetch fails" --> S["keep last good, warn, retry after cache.timeout_hot (300 s)"]
    B -- no --> C{"in Redis (oidc-discovery_<sha256 key>)?"}
    C -- yes --> V{"re-validates (issuer match, URL checks)?"}
    V -- yes --> U
    V -- no --> P
    C -- no --> P["fetch discovery URL (5 s timeout, TLS verified)"]
    P -- "issuer != issuer_url" --> E["OIDCDiscoveryError"]
    P -- ok --> M["cache in memory + Redis, TTL cache.timeout_cold (7 d)"]
    M --> U

Details that matter when changing this code:

  • The memory cache key is (discovery URL, expected issuer). Redis stores the same entry under a SHA-256 key. Redis data is never trusted before re-validation.
  • The returned issuer must equal issuer_url exactly. Endpoint values must be HTTP(S) URLs without userinfo or fragments.
  • A 60-second clock-skew tolerance applies to expiry checks.
  • If all required endpoints are configured explicitly, the service is not called. Providers may omit introspection_endpoint or end_session_endpoint; the platform requires them only for operations that use them.
  • Failed fetches are never cached. Discovery failures stop the operation that needs metadata; they never bypass authentication.

OAuth2 Proxy endpoint wiring

OAuth2 Proxy normally runs its own native discovery from the issuer. When authorization_url, token_url, user_info_url, or jwks_url is overridden, the operator disables native discovery and exports the resolved URLs (OIDC_SKIP_DISCOVERY, OIDC_AUTHORIZATION_URL, OIDC_TOKEN_URL, OIDC_USER_INFO_URL, OIDC_JWKS_URL in the site files ConfigMap; core/.../model/site_exporter.py). OAuth2 Proxy still verifies tokens and the issuer. An introspection_url override alone does not disable native discovery; Lua reads OIDC_INTROSPECTION_URL, which the operator exports when the URL is explicitly configured or discovery provides it — if neither supplies it, per-site export fails. Lua performs no discovery itself. When a JWT fails introspection, the proxy caches the failure negatively for cache_ttl (150 s) and redirects the browser to the Site's s/oauth2/sign_out?rd=<site_path> path (proxy/lua/jwt.lua).

Hub logout reads the stored OIDC credentials under oidc_<sub> in Redis. If they contain an ID token, the platform calls the provider's end-session endpoint: the discovered URL with an appended id_token_hint query parameter, or, when oidc.logout_url is configured explicitly, that value rendered as a sandboxed Jinja template with id_token available to the template. A provider failure is logged and never blocks the local logout: the local token cookie and the Flask session are always cleared, and the browser is redirected to /hub/. The Redis credentials entry is not deleted; it expires after cache.timeout_cold (default 7 days). The account_url setting is unrelated to logout; it is the account page that the Hub UI links to.

OAuth2 Proxy identity wiring

The per-site OAuth2 Proxy config (site/.../oauth2/oauth2-proxy/) defines how a browser session becomes an identity the proxy Lua layer can use:

  • Browser JWT to Lua: injectRequestHeaders rewrites the Authorization header with the session's access_token claim, prefixed Bearer, and preserveRequestValue: true keeps a caller-supplied header (master token or HTTP access token) when the session carries no claim. This is how the Lua introspection path receives the provider JWT for browser requests (site proxy — Authentication modules).
  • User header: X-Remote-User is set from the preferred_username claim; the proxy trusts it under the same rule as SEC-11.
  • Claim mapping: groups come from entitlements, the user ID from preferred_username, the email from email; audience validation accepts aud and azp claims and the configured extra audiences (default sites, set by oidc.extra_audiences); insecureAllowUnverifiedEmail is false.
  • Email domains: email_domains = ["*"] accepts any provider-verified email. The proxy does not filter people by domain: access decisions come from the user and group lists on the Site resource and the Lua checks.
  • CORS preflight: skip_auth_preflight is on, so browser OPTIONS requests pass without a login even on private Sites. Preflight carries no cookies and reads no content; the real request is still authenticated.
  • Sessions: the proxy cookie is named s, scoped to the site path on the platform host, Secure, HttpOnly, with per-request CSRF cookies.

Changing OIDC endpoints

After changing the effective endpoints: apply the Helm change, delete oidc-discovery_* keys from Redis, restart Hub and operator, reconcile the affected sites, and restart site pods that still hold old values. The full operational procedure is in sys-admin: secrets and integrations.

Accepted risks

These risks are accepted under the trust model. Each one needs a trusted actor (a site administrator or a platform operator), or it has a documented mitigation.

  • The optional site-custom container runs tenant code in the same Pod as the Site API. It shares the Pod loopback address, so it can call the API with no authentication. A NetworkPolicy cannot stop loopback traffic. This is an accepted risk: the container already mounts the site volume and receives the site Secret, so the API gives it no additional access. The API confines every path to the site root.
  • Sites share one origin. All sites use the host routing.host, and only the path makes them different. The OAuth2 Proxy cookie s has the site path as its Path, and the WebDAV cookies have <site path>s/. A cookie path is not a security boundary in a browser. A script on one site can send a request to another site, and the browser adds the visitor's cookies for that site. The script can read the response and call the Site API of the other site as the visitor, because the proxy accepts the cookie s as a credential. HttpOnly stops the theft of the cookie value, but it does not stop these requests. All sites also share localStorage and sessionStorage. The attack works only against a visitor who has an active session on the other site. A site administrator must publish the script, so the trust model accepts this risk. One host per site would remove it, because each site would then be a separate origin. Sites does not implement this (10 — Routing).
  • The chart default routing.hubHost: "" keeps the Hub and the websites on one origin, so a website script can act with the Hub session. This is an accepted, documented risk for local development and tests. Production installations must set routing.hubHost. The chart release notes (templates/NOTES.txt) and the Hub start log warn when it is empty.
  • The proxy reads the JWT claims (preferred_username, policies or entitlements) from the token payload without a local signature check. It uses the claims only after the IdP introspection endpoint returns active: true. The IdP introspects the full token string, thus a token with a changed payload is not active. The proxy does not use the claims of the introspection response, because IdPs do not always return custom claims such as entitlements there (proxy/lua/jwt.lua). This is an accepted design.
  • The Site proxy and the Hub accept a bearer token only when it belongs to the platform: when the token carries azp (the client that requested it), that client must be the platform OIDC client (OIDC_CLIENT_ID) or an extra audience; aud is the fallback for a provider that does not return azp. A token that the IdP issued to another client of the same realm is refused, so a service that holds a user token cannot replay it to a site or to the Hub API. The proxy reads the introspection response first and the decoded payload of the introspected token as the fallback; the Hub uses the introspection response. A token that carries neither claim is refused, because the client of the token cannot be established (SEC-30, SEC-32).
  • There is no general rate limiter, but the anonymous password-grant endpoint (POST /hub/api/v{1,2}/openid-connect/token/) is limited per client IP and per client IP and username, 20 attempts per minute each, in Redis (is_rate_limited). See 08 — authorization. Other surfaces rely on the negative auth cache (150 s), bounded Go caches (3 min time to live (TTL)), the bounded token-history queue, and NGINX connection settings. The password-grant limit slows a credential-stuffing attempt from one address; it does not stop one that uses many addresses.
  • golang.org/x/crypto carries an advisory that the Site API cannot reach. GO-2026-5932 reports the openpgp package as unmaintained and unsafe by design. The module is an indirect dependency of the Site API, and govulncheck finds no call to that package. The fix is a commit on the module main branch, not a tagged release, so the platform does not pin a pseudo-version. The dependency automation updates the module when a tagged release contains the fix.
  • A signed Redis cache entry can set class attributes. The restricted unpickler of the Redis cache controls which classes an entry can name, but the C unpickler does not let it check the BUILD opcode. Thus a signed entry can set attributes on an allowed class, for example SiteStatus. The change stays in the reading process until it restarts. To make such an entry, an attacker needs write access to Redis and the session secret key, because each entry must have a valid HMAC signature. The same key signs the Hub session cookie, so this attacker can already sign in to the Hub as an administrator. The risk thus adds little. The Redis NetworkPolicy allows only Hub and Operator Pods to connect to Redis. A pure-Python unpickler or a JSON format would remove the risk, but each cached read would then be 4 to 9 times slower (09 — Data storage).
  • Token revocation fails closed while the token-history database is unavailable. The Site API decode path refuses a token when it cannot confirm the revocation state. A loaded Bloom filter that does not contain the token hash still accepts the token without a database read, because the filter has no false negatives; a Bloom hit, or a filter that is not loaded, needs the database, and the decode answers 500 when the database is unavailable. A revoked HTTP access token therefore cannot be used during the outage; the accepted cost is that a token which cannot be confirmed is refused. The window is bounded: StartTokenDatabaseRecovery retries the database every 30 s and rebuilds the revocation filter and the endpoints when it opens, and StartTokenHistoryBackup writes a snapshot at startup and once per day as the source for POST /token/history/restore/. The API logs a warning for each refused check (07 — tokens and revocation).

Hardening details

  • The platform workloads (the Hub and its initializer, the operator, Redis, and the CronJobs) run as non-root users with the RuntimeDefault seccomp profile, a read-only root filesystem, no Linux capabilities, and no privilege escalation (sites.podSecurityContext and sites.containerSecurityContext in helm/ecmwf-sites/templates/_helpers.tpl). The Python containers write temporary files to an emptyDir at /tmp. The Hub image builds the asset bundles at build time, and the Hub keeps the webassets cache in /tmp/webassets-cache. Redis runs as UID 999, because the chart command replaces the image entrypoint that changes to the redis user. The chart sets no fsGroup, so that Kubernetes does not change the ownership of the data volume each time that a pod mounts it.
  • The Hub ClusterRole can delete PersistentVolumes, because the admin storage page removes the volumes of deleted Sites. RBAC cannot limit the delete verb by label. The chart adds the ValidatingAdmissionPolicy sites-hub-volume-deletion (helm/ecmwf-sites/templates/rbac-hub.yaml). It lets the sites-hub ServiceAccount delete a volume only when the volume has the label sites.ecmwf.int/app: site, its claimRef namespace is the release namespace, and its phase is Released. Thus a stolen Hub token cannot delete Bound volumes or volumes that Sites does not own. The chart renders the policy only when the cluster serves admissionregistration.k8s.io/v1 ValidatingAdmissionPolicy (Kubernetes 1.30 or later). On older clusters the RBAC rule is the only limit.
  • Signed URLs and session URLs are capability URLs: the signature query value and the /s/session/<token>/ path segment give access without a login. The logs do not record these values. The proxy access log writes REDACTED in their place in the request and referer fields (proxy/nginx/nginx.conf). The Site API request log writes signature=REDACTED (api/api/logger.go). The OAuth2 Proxy request log does not record the request URI (request_logging_format in oauth2-proxy/oauth2.cfg).
  • The proxy compares the master token in constant time. It compares HMAC-SHA1 digests that use the master token as the key, and it reads every byte of the digests (Hex64.constant_time_equals in proxy/lua/hex_64.lua).
  • The OAuth2 Proxy error page never shows debug details (show_debug_on_error = "false" in oauth2-proxy/oauth2.cfg), also when the platform debug setting is on. The container log has the details.
  • CSRF: OAuth2 Proxy uses per-request CSRF cookies. The Hub enforces a session-bound CSRF token (hub/csrf.py): an unsafe request that arrives with an authentication cookie (the Hub session cookie, the OIDC id-token cookie, or the access_token cookie) must present the token in the X-CSRF-Token header or the csrf_token form field, or it is rejected with
  • Bearer requests and requests without an authentication cookie are exempt. Session cookies are SameSite=Lax, a second layer of defence.
  • Session cookies are Secure, HttpOnly, SameSite=Lax throughout (OAuth2 Proxy: cookie_secure/cookie_httponly in oauth2.cfg; Hub: SESSION_COOKIE_SECURE/SESSION_COOKIE_SAMESITE in core/config/flask.py plus the Flask HttpOnly default).
  • The Hub authenticates the browser from the signed OIDC id-token cookie as well as the Hub session cookie: the claims are the JWS payload, and the handler accepts the cookie only when it carries a claims object with an expiry in the future (core/.../auth/authn.py). The Hub rebuilds the cached roles from the identity provider userinfo endpoint at most every five minutes (SESSION_CONTEXT_TTL_SECONDS, core/.../auth/view_utils.py), so a removed role takes effect without a new login; a failed rebuild keeps the previous roles (08 — authorization).
  • Identity reaches backends only through the proxy-controlled headers: the server context resets $x_remote_user to the empty string on every request (server.conf), and proxy.conf sends X-Remote-User: $x_remote_user and blanks X-Remote-User-Name and X-Remote-User-Admin on the request, so a client-forged header never reaches a site application. Only authentication handlers set the variable: JWT sessions contribute the username, Branca and session-URL tokens their identifier, master-token and signed-URL requests the fixed values master-token and SignedUrl, and anonymous public reads the value anonymous. The same access log records the client-supplied header (user="$http_x_remote_user"), so that log field is forgeable and is not an audit trail. Every authentication handler sets X-Remote-User, X-Remote-User-Name, and X-Remote-User-Admin on the response; the WebDAV location strips those headers from upstream responses (webdav.conf).
  • Master token comparisons avoid raw string timing: the Lua check compares HMAC-SHA1 digests keyed by the master token (hex_64.lua; the code comment explains why digest timing does not leak the token), and the Hub compares with hmac.compare_digest (core/.../auth/authn.py).
  • S3 proxying strips the issued credentials before the request reaches the backend application: s3.conf sets Authorization and X-Amz-Security-Token to empty.
  • The Hub session.permanent_lifetime value is currently inert: no code path sets session.permanent, so Hub sessions are browser-session cookies. Turning the 72 h lifetime on is a deliberate behaviour change, not a bug fix.

Behaviour requirements

ID Requirement Implemented in Verified by
SEC-01 The Go API performs no authentication. It binds only 127.0.0.1:10000; only the proxy routes to it, over the pod loopback address, and the liveness probe runs /api healthcheck in the container. The site NetworkPolicy (when enabled) permits ingress only on port 4180, from the routing namespace, the monitoring namespace, the metrics-gateway namespace, and same-namespace pods. site/.../api/main.go, site/.../api/app_config.py, proxy/nginx/api.conf, helm/ecmwf-sites/templates/networkpolicy-site.yaml api/main_test.go (TestListenAddressIsLoopbackOnly, TestHealthcheck), site/tests/test_api_app_config.py (exec probe, test_container_uses_port_10000, test_proxy_reaches_the_api_on_its_port), core/tests/test_helm_configuration.py (test_site_network_policy_opens_only_the_oauth2_proxy_port)
SEC-02 Public API requests into a .htpasswd-protected tree return 404 and are pruned from listings, including Repr-Digest. site/.../api/api/public_files.go, file_utils.go api/public_files_test.go (filtering and pruning cases), api/file_metadata_handlers_test.go (404, digest present/absent, including public responses)
SEC-03 Anonymous requests on public sites are limited to GET, HEAD, OPTIONS, PROPFIND, SEARCH (see Why public sites are read-only). proxy/lua/public_auth.lua —
SEC-04 The health location allows the pod loopback address and trusted_ips only; /s/metrics allows trusted_ips only; an empty list denies all metrics requests. proxy/nginx/health.conf, metrics.conf site/tests/test_proxy_app_config.py (health render)
SEC-05 OIDC discovery validates the issuer exactly, caches successes in memory and Redis with TTL 7 d, and never caches failures. core/.../service/oidc_discovery.py core/tests/test_oidc_discovery.py
SEC-06 Any of the four endpoint overrides (authorization, token, user_info, jwks) switches OAuth2 Proxy from native discovery to exported URLs. core/.../model/site_exporter.py, oauth2/app_config.py core/tests/test_oidc_site_export.py
SEC-07 CSP frame-ancestors is derived from trusted_domains (domain + subdomains); an empty list yields 'self' only. _helpers.tpl, routing_rules.py, cors.conf site/tests/test_proxy_app_config.py (site-proxy component)
SEC-08 Failed token validations are cached negatively for 150 s; successful Branca decodes at most 10 s. proxy/lua/auth_utils.lua, auth_cache.lua site/tests/test_proxy_lua_branca.py, test_proxy_lua_jwt.py (cache behaviour; the production 150 s value is stubbed in tests)
SEC-09 The download-folder shortcut applies the site type's access module; public sites use the Go public router, so .htpasswd-protected trees answer 404, and private sites require the site authentication chain. proxy/nginx/api.conf site/tests/test_proxy_app_config.py (render wiring for both site types)
SEC-10 A Hub request with an unsafe method and an authentication cookie (the session cookie, the OIDC id-token cookie, or the access_token cookie) must present the session CSRF token via the X-CSRF-Token header or the csrf_token form field, compared with hmac.compare_digest; failures answer 403. Safe methods, bearer requests, and requests without an authentication cookie are exempt. hub/csrf.py hub/tests/test_csrf.py
SEC-11 The identity headers sent to site applications are proxy-controlled: the server context resets the identity variable on every request, the proxy overwrites X-Remote-User, and it clears X-Remote-User-Name and X-Remote-User-Admin, so client-forged values never reach a backend; only authentication handlers set the response values. proxy/nginx/server.conf, proxy.conf site/tests/test_proxy_app_config.py (test_upstream_identity_headers_cannot_be_forged)
SEC-12 Master token checks avoid raw timing comparisons: Lua compares HMAC-SHA1 digests keyed by the master token; the Hub compares with hmac.compare_digest. proxy/lua/hex_64.lua, core/.../auth/authn.py site/tests/test_proxy_lua_hex64.py (digest comparison), core/tests/test_master_token_compare.py
SEC-13 S3 requests reach the backend application without the issued credentials: the proxy blanks Authorization and X-Amz-Security-Token. proxy/nginx/s3.conf — (stripping is not asserted; test_s3_app_config.py covers the app config)
SEC-14 WebDAV basic-auth sessions are remembered for 1 hour in a Secure, HttpOnly, SameSite=Lax cookie scoped to the site; on expiry the proxy rotates the realm nonce and forces a fresh challenge. The username and any .htpasswd file accounts are ignored: only master tokens and Branca tokens pass, and the public-site anonymous fallback requires a path with no applicable .htpasswd. proxy/lua/basic.lua, webdav_auth.lua site/tests/test_proxy_lua_basic.py (authorization decisions and .htpasswd challenge; the cookie expiry and realm rotation are not asserted)
SEC-15 The files ConfigMap <id>-files holds no platform secret. The OAuth2 Proxy cookie secret is a per-site HMAC derivation of session.secret_key in the Secret <id>-oauth2, and the OIDC client secret is a read-only file from oidc-secrets; only site-oauth2 receives them. core/.../model/site_exporter.py (derive_site_oauth2_cookie_secret, to_oauth2_secret, to_files_config_map), site/.../oauth2/app_config.py, operator/.../site_create.py, site_update.py core/tests/test_site_oauth2_secret.py, site/tests/test_oauth_app_config.py, site/tests/test_custom_app_config.py, operator/tests/test_site_create.py, test_site_update.py, test_site_create_handler.py
SEC-16 Configuration loading fails when session.secret_key is missing or shorter than 32 UTF-8 bytes; the Redis cache never derives its signing key from an empty secret. When CACHE_REDIS_PASSWORD is set, the chart Redis server requires it, and the value never appears in the process arguments. core/.../config/schema/models.py (validate_session_secret_key), core/.../service/redis_cache.py (_cache_mac_key), helm/.../templates/deployment-redis.yaml core/tests/test_config_schema.py (TestSessionSecretKeyValidation), core/tests/test_redis_cache_load_object.py, core/tests/test_helm_configuration.py (Redis password tests)
SEC-17 The Hub (with its initializer), operator, Redis, and CronJob pods run as non-root (UID and GID 65534; Redis 999) with the RuntimeDefault seccomp profile. Every container has a read-only root filesystem, drops all capabilities, and does not allow privilege escalation. The Python containers get an emptyDir at /tmp. The Hub image contains the built asset bundles, and the Hub does not write to its package directory. helm/ecmwf-sites/templates/_helpers.tpl, deployment-hub.yaml, deployment-operator.yaml, deployment-redis.yaml, cronjob-*.yaml, hub/.../assets.py, hub/.../server.py, hub/Dockerfile core/tests/test_helm_configuration.py (test_platform_pods_run_hardened), hub/tests/test_assets.py, utilities/tests/dockerfile-policy.test.sh
SEC-18 The sites-hub ServiceAccount can delete a PersistentVolume only when the volume has the label sites.ecmwf.int/app: site, its claimRef namespace is the release namespace, and its phase is Released. The admin storage page lists only these volumes. The chart renders the admission policy only when the cluster serves ValidatingAdmissionPolicy. helm/ecmwf-sites/templates/rbac-hub.yaml (sites-hub-volume-deletion), core/.../kubernetes/kubernetes_dao.py (get_released_volumes) core/tests/test_helm_configuration.py (test_hub_volume_deletion_policy), core/tests/test_kubernetes_api_dao.py (test_get_released_volumes_keeps_only_site_volumes_of_the_namespace)
SEC-19 The request logs do not record capability URL credentials. The proxy access log replaces both the signed URL signature value and the session URL token with REDACTED, in the request and the referer. The Site API request log replaces the signed URL signature value; it never receives the session URL token, which nginx rewrites away. The OAuth2 Proxy request log does not record the request URI. proxy/nginx/nginx.conf (log_format main), api/api/logger.go (GinLoggerFormatter), oauth2-proxy/oauth2.cfg (request_logging_format) site/tests/test_proxy_app_config.py (test_access_log_redacts_signed_url_signatures_and_session_tokens), site/tests/test_oauth_app_config.py (test_request_log_does_not_contain_the_request_uri), api/api/logger_test.go
SEC-20 The proxy compares the master token digest in constant time. The OAuth2 Proxy error page never shows debug details. proxy/lua/hex_64.lua (constant_time_equals), oauth2-proxy/oauth2.cfg (show_debug_on_error) site/tests/test_proxy_lua_hex64.py (test_constant_time_equals_reads_every_byte), site/tests/test_oauth_app_config.py (test_error_page_never_shows_debug_details)
SEC-21 Site containers do not show the release namespace or the Kubernetes API server address. Every site container and init container sets NAMESPACE and the KUBERNETES_* variables of the kubernetes Service to empty values. Kubelet always injects the KUBERNETES_* variables, also with enableServiceLinks: false, and a container variable with the same name replaces them. The masked variables come after extra_env, and the Hub form rejects these names in extra_env. This does not block network access: site pods can still connect to the API server on TCP 443, but they have no service-account token. core/.../config/constants.py (SITE_MASKED_ENVIRONMENT_NAMES), core/.../model/site_app_config.py (_container, masked_env_var_list), site/.../initializer/app_config.py, site/.../mirror/app_config.py, core/.../model/site_exporter.py (SITE_PLATFORM_ENVIRONMENT_NAMES) site/tests/test_site_container_environment.py, site/tests/test_mirror_app_config.py, core/tests/test_site_applications_validation.py (test_masked_variables_are_reserved)
SEC-22 The Hub pages load scripts, styles, and fonts only from the Hub origin. The Hub CSP allows only 'self' (and data: images); it blocks plugins and other base URLs. The image build installs the frontend packages from the npm lockfile (exact versions, sha512 integrity) without install scripts, and copies only the allow-listed files. hub/frontend/ (package-lock.json, vendor-files.json, copy-vendor.mjs), hub/Dockerfile, hub/.../routing_rules.py (HUB_CSP_SOURCES), helm/.../templates/ingress-minion.yaml, hub/.../templates/ hub/tests/test_vendor_assets.py, hub/tests/test_routing_rules.py (test_gateway_csp_allows_only_the_hub_origin, test_ingress_minion_snippet_has_the_hub_csp), hub/frontend/tests/copy-vendor.test.mjs, utilities/tests/dockerfile-policy.test.sh
SEC-23 A request that changes data (any method that is not GET, HEAD, OPTIONS, PROPFIND, or SEARCH) on /s/api/v2/session-files/ is accepted only for a session URL with write permission, a token with write permission, or a site administrator. A user who may only view the site (the staff lists), and a read-only session URL combined with a viewer token, are refused with 403. The Site API behind this location has no authentication of its own. proxy/lua/session_files_auth.lua, proxy/nginx/api.conf site/tests/test_proxy_lua_session_files_auth.py, site/tests/test_proxy_app_config.py (test_session_files_location_uses_its_own_access_module)
SEC-24 The path scope of a session URL is checked on the path that the proxy acts on. The Site API removes the query, decodes the request path once, and resolves the dot segments and repeated slashes (the steps of the nginx $uri), and the path must also stay in the scope after the second decode pass of the file API (maybePathUnescape). Encoded dot segments (%2e%2e), encoded slashes (%2f), and double-encoded forms (%252e%252e) cannot leave the scope. The bare session URL is a request for the site root, so it is inside the scope only for a session without scope. A request path with an invalid encoding or a NUL byte is refused with 400. The file API refuses a match pattern with a .. segment for every request, so that a bulk delete or a listing cannot leave the requested path. The scope check also resolves the physical path of the request, so a symlink cannot make the request act outside the scope despite a request path inside it. site/.../api/api/session_urls.go (extractResourceFromRequestURI, isSessionRequestWithinScope, isResolvedPathWithinScope), site/.../api/api/models.go (hasParentSegment), proxy/lua/session_url.lua, proxy/nginx/server.conf site/.../api/api/session_urls_test.go (TestExtractResourceFromRequestURI, TestV2VerifySessionUrl, TestV2VerifySessionUrlRejectsSymlinkEscape), site/.../api/api/files_test.go (TestDeleteFile_MatchPattern_CannotLeaveTheRequestedPath)
SEC-25 The LDAP client limits what a directory server can make it buffer. A response message above 16 MiB, a length field with more than 4 octets, and a search that returns more than 100000 entries are refused with an LDAPError. With ldap.start_tls: true the client upgrades an ldap:// connection to TLS with the StartTLS extended operation (1.3.6.1.4.1.1466.20037) before the bind, using the same context as ldaps:// (TLS 1.2 minimum, certificate and host name verified), so the bind password does not cross the network in clear text. The option is refused with an ldaps:// connection, which is already encrypted. An ldap:// connection with start_tls false reports the ldap-cleartext consistency warning. core/.../integrations/directory/ldap_client.py, core/.../config/schema/models.py (LDAPSchema.start_tls), core/.../config/consistency.py (_directory_rules) core/tests/test_ldap_client.py, core/tests/test_ldap_service.py, core/tests/test_config_consistency.py (test_ldap_cleartext_bind_warns, test_ldap_start_tls_does_not_warn, test_ldaps_does_not_warn)
SEC-26 The Site proxy image gives UID 65534 ownership of only the directories that nginx writes at run time: the log directory, which holds the pid file, and the five temporary upload buffer directories. The rest of the OpenResty tree, including the directory that holds the loaded dynamic modules and the rendered configuration, stays owned by root and is not group- or world-writable. site/proxy.Dockerfile utilities/tests/dockerfile-policy.test.sh (writable_nginx_chmods self-check)
SEC-27 The Operator ServiceAccount can get, list, and patch PersistentVolumes cluster-wide. Kubernetes RBAC cannot limit the patch verb by label, so the permission is documented here and in the chart README instead of restricted. The Operator needs it only for the volumes of a Site: it finds the released volume by the Site labels or by the claim name, then writes the Site labels and the claim reference of the new PersistentVolumeClaim. The Operator cannot delete a PersistentVolume (SEC-18 gives the Hub delete path its own admission policy). helm/ecmwf-sites/templates/rbac-admin.yaml, helm/ecmwf-sites/README.md (Admin-scoped RBAC) Documentation (no automated test)
SEC-28 Site pods satisfy the restricted Pod Security Standard. The pod runs as UID and GID 65534 with fsGroup 65534, runAsNonRoot, and the RuntimeDefault seccomp profile. Every container, including the initializer and the mirror, sets allowPrivilegeEscalation: false, privileged: false, runAsNonRoot, and drops all capabilities. Every site application listens on a port above 1024, so no capability is needed. The chart creates no namespace, so the installer labels the release namespace pod-security.kubernetes.io/enforce: restricted; see the cluster prerequisites. A custom application image that needs a Linux capability does not run. core/.../model/site_exporter.py, core/.../model/site_app_config.py, site/.../applications/initializer/app_config.py, site/.../applications/mirror/app_config.py, docs/sys-admin/02-cluster-prerequisites.md site/tests/test_site_pod_security.py, site/tests/test_mirror_app_config.py
SEC-29 The Hub accepts ingress on port 8080 only from the routing namespace (ingressNamespace or gatewayNamespace) and from the monitoringNamespace and metricsGatewayNamespace. The /hub/metrics/ route has no authentication of its own and lists all sites; the generated metrics are cached for a few seconds, so the listing runs at most once per scrape interval. Set hub.metrics_scrape_token (env HUB_METRICS_SCRAPE_TOKEN) to require a bearer token on the route, and point serviceMonitor.hub.bearerTokenSecret at the Secret key that holds it. The routing layer denies the path publicly, and the policy keeps the route away from every other Pod. The policy restricts ingress only, so the Hub keeps its connections to Redis, the Kubernetes API, the identity provider, and the other integrations. helm/ecmwf-sites/templates/networkpolicy-hub.yaml, helm/ecmwf-sites/templates/ingress-hub.yaml, helm/ecmwf-sites/templates/httproute-hub.yaml, helm/ecmwf-sites/templates/servicemonitor.yaml, hub/.../app/hub/views.py (metrics) core/tests/test_helm_configuration.py (test_hub_network_policy_opens_only_the_hub_port, test_network_policy_switch, test_service_monitor_hub_bearer_token), hub/tests/test_hub_metrics.py
SEC-30 The Site proxy accepts a JWT only when it belongs to the platform. A token that carries azp (the client that requested it) is accepted only when azp names the platform OIDC client (OIDC_CLIENT_ID) or an extra audience; aud is the fallback for a provider that does not return azp. A token that the IdP issued to another client of the same realm is refused, so a service that holds a user token cannot replay it to a site. The check reads the introspection response first and the decoded payload of the introspected token as the fallback. A token with neither claim is refused, because the client of the token cannot be established. The refused token is cached as invalid, so a repeat request does not introspect it again. When neither OIDC_CLIENT_ID nor OIDC_EXTRA_AUDIENCES is set, the check fails closed. The accepted audiences come only from OIDC_CLIENT_ID and the platform setting oidc.extra_audiences, which reaches the proxy as the OIDC_EXTRA_AUDIENCES environment variable declared with env in nginx.conf; there is no built-in default. proxy/lua/jwt.lua (allowed_token_clients, token_client_allowed), proxy/lua/auth_utils.lua, proxy/nginx/nginx.conf (the env list), proxy/app_config.py (_oidc_env), oauth2/oauth2-proxy/alpha-oauth2.cfg (extraAudiences, audienceClaims) site/tests/test_proxy_lua_jwt.py, site/tests/test_proxy_lua_env.py, site/tests/test_oauth_app_config.py
SEC-31 The Hub templates contain no inline script, no inline event handler, and no javascript: link, so the Hub content security policy sends script-src 'self' without 'unsafe-inline', in the response header of the Hub and in the NGINX minion snippet of its Ingress. Page behaviour lives in the static bundles (js/context.js, js/page-scripts.js) and reads the data-* attributes of the page. A flashed message travels as a JSON data attribute and is escaped before the notification inserts it. Styles keep 'unsafe-inline' for the style blocks and style attributes of the templates. hub/.../templates/, hub/.../static/js/context.js, hub/.../static/js/page-scripts.js, hub/.../assets.py, hub/.../routing_rules.py (HUB_CSP_SOURCES), helm/ecmwf-sites/templates/ingress-minion.yaml hub/tests/test_template_scripts.py, hub/tests/test_routing_rules.py
SEC-32 The Hub accepts a bearer access token only when it belongs to the platform. The identity provider introspection accepts any active token of the realm, and the audience check of the OIDC library is off by default, so the platform checks the client itself: azp (when present) must name the platform OIDC client (OIDC_CLIENT_ID) or an extra audience (oidc.extra_audiences), aud is the fallback, and a token with neither claim is refused. A refused token does not keep its claims on the request. When neither the client id nor an extra audience is configured, the check fails closed. core/.../auth/authn.py (OIDCTokenHandler), core/.../auth/token_audience.py core/tests/test_token_audience.py, core/tests/test_authentication_chain_builder.py (TestOIDCTokenHandler)
SEC-33 The anonymous password-grant endpoint (POST /hub/api/v{1,2}/openid-connect/token/) limits the attempts per client IP and per client IP and username, 20 attempts per minute each, in Redis (is_rate_limited, a fixed 60 s window keyed by a SHA-256 of the identifier). The client IP is the address that the proxy middleware resolves, not the X-Forwarded-For header; the username is trimmed and lower-cased for the key. When Redis is unavailable the call is not limited, because the identity provider is the authority. hub/.../app/api/common.py (_token_grant_rate_limited, TOKEN_GRANT_MAX_ATTEMPTS, TOKEN_GRANT_WINDOW_SECONDS), core/.../misc/rate_limit.py (is_rate_limited), core/.../service/redis_cache.py (increment) hub/tests/test_token_grant_throttle.py, core/tests/test_rate_limit.py, core/tests/test_redis_cache_increment.py
SEC-34 The Hub authenticates the browser from the signed OIDC id-token cookie (or an access_token cookie) only when it carries a claims object with a future exp, and it rebuilds the cached roles from the identity provider userinfo endpoint at most every five minutes, so a removed role takes effect without a new login. A failed rebuild keeps the previous roles. core/.../auth/authn.py (CookieHandler, _token_claims_are_current), core/.../auth/view_utils.py (SESSION_CONTEXT_TTL_SECONDS) core/tests/test_authentication_chain_builder.py, core/tests/test_auth_view_utils.py