From 422027ba90f123be5c2730b86321d4f8020ccba5 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Mon, 9 Nov 2026 14:28:42 +0000 Subject: [PATCH 1/2] fix(security): close cookie, workflow and image-lint findings Mark the JupyterHub logout cookies Secure on HTTPS, and allow COOKIE_SECURE=false so a plain-HTTP portal can still clear them. Limit the mirror and image-publish workflows to contents: read. Pin the base image digests and Alpine packages, run as uid 10001, and use a JSON healthcheck. --- .github/workflows/ebrains.yml | 3 +++ .github/workflows/publish_images.yml | 3 +++ Dockerfile | 26 +++++++++++-------- docs/context/session-management.md | 1 + .../configurations/SecurityConfiguration.java | 20 +++++++++++--- src/main/resources/application.yml | 4 +++ .../TermsOfUseSecurityChainTest.java | 8 ++++-- 7 files changed, 49 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ebrains.yml b/.github/workflows/ebrains.yml index 1e6066ba..03b00367 100644 --- a/.github/workflows/ebrains.yml +++ b/.github/workflows/ebrains.yml @@ -6,6 +6,9 @@ on: tags: - "*" +permissions: + contents: read + jobs: to_ebrains: runs-on: ubuntu-latest diff --git a/.github/workflows/publish_images.yml b/.github/workflows/publish_images.yml index 2eda45fd..150b1c47 100644 --- a/.github/workflows/publish_images.yml +++ b/.github/workflows/publish_images.yml @@ -4,6 +4,9 @@ on: release: types: [published] +permissions: + contents: read + env: HARBOR_REGISTRY: docker-registry.ebrains.eu DOCKER_BUILDKIT: 1 diff --git a/Dockerfile b/Dockerfile index 011211e9..2d0d4009 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ ####################################################### # Build the spring boot maven project ####################################################### -FROM maven:3.9.16-amazoncorretto-21 AS mvn-build-env +# Digest is the multi-arch index for this tag (registry, 2026-11-09). +FROM maven:3.9.16-amazoncorretto-21@sha256:efdf660ec74911594e2a7f642e177ffba21b741c8e848223bf41a6d0fc367692 AS mvn-build-env LABEL maintainer="Thanasis Karampatsis " ENV CODE_PATH="/opt/code" @@ -19,7 +20,8 @@ RUN mvn -B -ntp clean package ####################################################### # Setup the running container ####################################################### -FROM amazoncorretto:21-alpine3.24 +# Digest is the multi-arch index for this tag (registry, 2026-11-09). +FROM amazoncorretto:21-alpine3.24@sha256:e37320b8ccdb61ed4272edf1f3c9836ca4ea660b37f048dbb6858901d5322145 ####################################################### # Setting up timezone @@ -37,11 +39,13 @@ ENV FRAMEWORK_LOG_LEVEL="INFO" WORKDIR /opt -# libcrypto3/libssl3 are refreshed because the amazoncorretto base image ships -# openssl 3.5.7-r0, which carries CVE-2026-14456, CVE-2026-63073 and -# CVE-2026-75803 (CVSS >= 8.0, blocks the container SCA gate). Alpine 3.24 -# fixes them in openssl 3.5.8-r0. -RUN apk add --no-cache --upgrade libcrypto3 libssl3 curl +# Pin Alpine 3.24 versions. openssl 3.5.9-r0 is past 3.5.8-r0, which fixed +# CVE-2026-14456, CVE-2026-63073 and CVE-2026-75803. The base image may still +# ship the older build, so install these explicitly. +RUN apk add --no-cache \ + libcrypto3=3.5.9-r0 \ + libssl3=3.5.9-r0 \ + curl=8.23.0-r0 ####################################################### # Prepare the spring boot application files @@ -50,11 +54,11 @@ COPY --from=mvn-build-env /opt/code/target/platform-backend.jar /usr/share/jars/ VOLUME /opt/platform/api -RUN addgroup -S appgroup && adduser -S appuser -G appgroup \ +RUN addgroup -g 10001 appgroup && adduser -D -H -u 10001 -G appgroup appuser \ && mkdir -p /opt/platform/api \ - && chown -R appuser:appgroup /opt/platform/api /usr/share/jars + && chown -R 10001:10001 /opt/platform/api /usr/share/jars -USER appuser +USER 10001 ENTRYPOINT ["java", "--add-opens", "java.base/java.io=ALL-UNNAMED", "-Daeron.term.buffer.length", "-jar", "/usr/share/jars/platform-backend.jar"] EXPOSE 8080 -HEALTHCHECK --start-period=60s CMD curl --fail --silent --show-error http://localhost:8080/services/actuator/health | grep -q '"status":"UP"' +HEALTHCHECK --start-period=60s CMD ["sh", "-c", "set -o pipefail; curl --fail --silent --show-error http://localhost:8080/services/actuator/health | grep -q '\"status\":\"UP\"'"] diff --git a/docs/context/session-management.md b/docs/context/session-management.md index f4f069e3..2ae1b0ce 100644 --- a/docs/context/session-management.md +++ b/docs/context/session-management.md @@ -37,6 +37,7 @@ Not limited. One account may hold several portal sessions, laptop sign-ins and a - DPoP for the laptop client: with `KEYCLOAK_CLI_REQUIRE_DPOP` on, the laptop client's (`mip-cli-`) tokens must be DPoP-bound (RFC 9449) and arrive with the `DPoP` scheme, and Spring verifies the DPoP proof (signature, `htu`/`htm`, `ath` — the SHA-256 of the access token, `iat`, replay and the token's `cnf.jkt` against the proof's key). Platform-client tokens (portal, notebooks) keep working over `Bearer`; remove the flag once every federation has switched. The proof's `htu` is the request URL, so nginx must pass `X-Forwarded-Proto`/`X-Forwarded-Host` for the backend location. - Turn-on order: while the flag is off, a DPoP-bound token sent with `Bearer` is accepted without a proof (the binding is not enforced). With the flag on before clients send DPoP, every laptop user fails. Turn on Keycloak DPoP for the laptop client, this flag, and a DPoP-capable mip client release in one change window. - The proof replay check keeps recent `jti` values in memory, per backend replica: with more than one replica, a proof can be replayed on another replica within the short `iat` window (it stays bound to one method and URL). Acceptable for now; use a shared cache if it matters later. +- JupyterHub login cookies expired on portal sign-out are marked Secure when the request is HTTPS (`COOKIE_SECURE`, default true, including `X-Forwarded-Proto`). Set `COOKIE_SECURE=false` for a portal served over plain HTTP, so those cookies can still be cleared. ## Not available diff --git a/src/main/java/hbp/mip/configurations/SecurityConfiguration.java b/src/main/java/hbp/mip/configurations/SecurityConfiguration.java index 6393253a..87cf44cc 100644 --- a/src/main/java/hbp/mip/configurations/SecurityConfiguration.java +++ b/src/main/java/hbp/mip/configurations/SecurityConfiguration.java @@ -3,6 +3,7 @@ import hbp.mip.user.ActiveUserService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -17,6 +18,7 @@ import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; @@ -90,6 +92,10 @@ public class SecurityConfiguration { @Value("${authentication.cli_require_dpop:false}") private boolean cliRequireDpop; + // Secure on HTTPS. COOKIE_SECURE=false keeps clearing these cookies over plain HTTP. + @Value("${authentication.secure_cookies:true}") + private boolean secureCookies; + @Value("${server.port:8080}") private int serverPort; @@ -163,8 +169,7 @@ SecurityFilterChain clientSecurityFilterChain(HttpSecurity http, .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter( ClearSiteDataHeaderWriter.Directive.CACHE, ClearSiteDataHeaderWriter.Directive.STORAGE))) - .addLogoutHandler(new CookieClearingLogoutHandler( - expiredHubCookie("jupyterhub-hub-login"), expiredHubCookie("jupyterhub-session-id"))) + .addLogoutHandler(this::clearHubCookies) .deleteCookies("JSESSIONID") .logoutSuccessHandler(successHandler)); @@ -222,10 +227,19 @@ OidcSessionRegistry oidcSessionRegistry() { // ponytail: assumes the hub's default base path; a setting if a deployment moves it. A notebook server's own // cookie is not cleared, because its path holds the username: it ends at the hub's next token refresh. - private static Cookie expiredHubCookie(String name) { + private void clearHubCookies(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) { + new CookieClearingLogoutHandler( + expiredHubCookie("jupyterhub-hub-login", request.isSecure()), + expiredHubCookie("jupyterhub-session-id", request.isSecure())) + .logout(request, response, authentication); + } + + private Cookie expiredHubCookie(String name, boolean requestSecure) { Cookie cookie = new Cookie(name, null); cookie.setPath("/notebook/hub/"); cookie.setMaxAge(0); + cookie.setSecure(secureCookies && requestSecure); return cookie; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 964a015d..b12cb4c2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -70,6 +70,10 @@ authentication: cli_require_dpop: ${KEYCLOAK_CLI_REQUIRE_DPOP:false} # Portal sessions end at this age however active they are (8h or PT8H). session_max_age: ${SESSION_MAX_AGE:8h} + # Secure attribute on the JupyterHub cookies cleared at sign-out. Default on: the + # attribute is set when the request is HTTPS (including X-Forwarded-Proto). Set + # COOKIE_SECURE=false so a portal served over plain HTTP can still clear them. + secure_cookies: ${COOKIE_SECURE:true} ### EXTERNAL SERVICES ### services: diff --git a/src/test/java/hbp/mip/configurations/TermsOfUseSecurityChainTest.java b/src/test/java/hbp/mip/configurations/TermsOfUseSecurityChainTest.java index a29528ac..30a196e8 100644 --- a/src/test/java/hbp/mip/configurations/TermsOfUseSecurityChainTest.java +++ b/src/test/java/hbp/mip/configurations/TermsOfUseSecurityChainTest.java @@ -307,7 +307,9 @@ void logoutClearsTheSiteDataOverHttps() throws Exception { mvc.perform(post("/services/logout").contextPath("/services").secure(true).session(session("alice"))) .andExpect(header().string("Clear-Site-Data", "\"cache\", \"storage\"")) .andExpect(cookie().maxAge("jupyterhub-hub-login", 0)) - .andExpect(cookie().path("jupyterhub-hub-login", "/notebook/hub/")); + .andExpect(cookie().path("jupyterhub-hub-login", "/notebook/hub/")) + .andExpect(cookie().secure("jupyterhub-hub-login", true)) + .andExpect(cookie().secure("jupyterhub-session-id", true)); } @Test @@ -316,7 +318,9 @@ void loopbackLogoutEndsTheSessionWithoutACsrfToken() throws Exception { MockHttpSession session = session("alice"); mvc.perform(post("/services/logout").contextPath("/services").session(session)) - .andExpect(status().is3xxRedirection()); + .andExpect(status().is3xxRedirection()) + .andExpect(cookie().secure("jupyterhub-hub-login", false)) + .andExpect(cookie().secure("jupyterhub-session-id", false)); assertThat(session.isInvalid()).isTrue(); } From d9f7de64ca93cceb2df18075a39e1adea25fd8d5 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Mon, 9 Nov 2026 14:35:15 +0000 Subject: [PATCH 2/2] fix(docker): install Alpine zlib 1.4-r0 The Corretto base image ships zlib 1.3.2-r1, which OSV scores as CVE-2026-4738 and which fails the container CVSS gate. 1.4-r0 is the fixed Alpine 3.24 package. --- Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2d0d4009..58502109 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,12 +40,13 @@ ENV FRAMEWORK_LOG_LEVEL="INFO" WORKDIR /opt # Pin Alpine 3.24 versions. openssl 3.5.9-r0 is past 3.5.8-r0, which fixed -# CVE-2026-14456, CVE-2026-63073 and CVE-2026-75803. The base image may still -# ship the older build, so install these explicitly. +# CVE-2026-14456, CVE-2026-63073 and CVE-2026-75803. zlib 1.4-r0 fixes +# CVE-2026-4738 (the base image still ships 1.3.2-r1). RUN apk add --no-cache \ libcrypto3=3.5.9-r0 \ libssl3=3.5.9-r0 \ - curl=8.23.0-r0 + curl=8.23.0-r0 \ + zlib=1.4-r0 ####################################################### # Prepare the spring boot application files