From 77440db8f794aa67e794037aa836271a8b0bbd39 Mon Sep 17 00:00:00 2001 From: palehazy Date: Sat, 25 Jul 2026 21:59:39 -0500 Subject: [PATCH] Harden hosted Chat2DB origin boundary --- .github/workflows/hazyforge-image.yml | 7 ++ HAZYFORGE.md | 22 +++--- charts/chat2db/templates/deployment.yaml | 4 ++ charts/chat2db/templates/networkpolicy.yaml | 16 ++++- charts/chat2db/values.yaml | 6 ++ .../config/web/interceptor/CorsFilter.java | 55 +++++++++++++-- .../config/interceptor/CorsFilterTest.java | 67 ++++++++++++++++--- 7 files changed, 152 insertions(+), 25 deletions(-) diff --git a/.github/workflows/hazyforge-image.yml b/.github/workflows/hazyforge-image.yml index 018c445f33..0ae449e27b 100644 --- a/.github/workflows/hazyforge-image.yml +++ b/.github/workflows/hazyforge-image.yml @@ -105,6 +105,7 @@ jobs: --tmpfs /data:rw,noexec,nosuid,size=1g,uid=10001,gid=10001 \ --publish 127.0.0.1:18025:10825 \ --env CHAT2DB_COMMUNITY_ENCRYPTION_KEY_FILE=/run/secrets/chat2db-community-encryption.key \ + --env CHAT2DB_WEB_CORS_ALLOWED_ORIGIN=https://chat2db.hazyforge.io \ --volume "${key_file}:/run/secrets/chat2db-community-encryption.key:ro" \ "${image}" @@ -122,6 +123,12 @@ jobs: curl --fail --silent --show-error \ http://127.0.0.1:18025/api/system \ --output /tmp/system.json + test "$(curl --silent --output /dev/null --write-out '%{http_code}' \ + --header 'Origin: https://chat2db.hazyforge.io' \ + http://127.0.0.1:18025/api/system)" = "200" + test "$(curl --silent --output /dev/null --write-out '%{http_code}' \ + --header 'Origin: https://attacker.example' \ + http://127.0.0.1:18025/api/system)" = "403" test "$(docker inspect --format '{{.Config.User}}' "${container}")" = "10001:10001" - name: Upload packaged application diff --git a/HAZYFORGE.md b/HAZYFORGE.md index ee3907f037..550a80b599 100644 --- a/HAZYFORGE.md +++ b/HAZYFORGE.md @@ -8,19 +8,15 @@ retained. The built container is private and is only for HazyForge internal use. ## Security boundary Chat2DB Community is a single-user, local-first application. It has no accounts, -authorization, or tenant isolation. The Anvil Primaris deployment therefore has -no public route and applies a default-deny ingress policy. Do not add an Ingress, -Gateway API route, load balancer, or shared-user proxy without first changing -the application security model and reviewing the upstream license. +authorization, or tenant isolation. The Anvil Primaris deployment therefore +keeps Chat2DB on a private ClusterIP and permits ingress only from the exact +Chat2DB proxy workload in the shared `oauth2-proxy` namespace. The public route, +ZITADEL session enforcement, immutable subject allowlist, and auth-header +stripping are owned by that central platform deployment. -Access it over the Kubernetes API from a trusted administrator workstation: - -```bash -kubectl --context anvil-admin-anvil-primaris \ - --namespace chat2db port-forward service/chat2db 10825:10825 -``` - -Then open `http://127.0.0.1:10825`. +The hosted origin is an exact, validated HTTPS value. Wildcard CORS origins, +wildcard OIDC redirect URIs, direct publication of port 10825, and domain-wide +identity allowlists are not supported by this deployment. ## Durable state and secrets @@ -32,6 +28,8 @@ Then open `http://127.0.0.1:10825`. unreadable. - The private GHCR pull credential comes from the existing `anvil-primaris-ghcr-read-pat` Key Vault secret. +- The dedicated ZITADEL client ID and secret, oauth2-proxy cookie key, and exact + allowed subject are projected from Azure Key Vault by External Secrets. ## Image diff --git a/charts/chat2db/templates/deployment.yaml b/charts/chat2db/templates/deployment.yaml index 74ec3df741..bce0a8d531 100644 --- a/charts/chat2db/templates/deployment.yaml +++ b/charts/chat2db/templates/deployment.yaml @@ -38,6 +38,10 @@ spec: value: {{ .Values.encryptionKey.mountPath | quote }} - name: JAVA_TOOL_OPTIONS value: "-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError" + {{- with .Values.cors.allowedOrigin }} + - name: CHAT2DB_WEB_CORS_ALLOWED_ORIGIN + value: {{ . | quote }} + {{- end }} ports: - name: http containerPort: 10825 diff --git a/charts/chat2db/templates/networkpolicy.yaml b/charts/chat2db/templates/networkpolicy.yaml index f9599ce304..57e2ba6d2d 100644 --- a/charts/chat2db/templates/networkpolicy.yaml +++ b/charts/chat2db/templates/networkpolicy.yaml @@ -2,7 +2,7 @@ apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: - name: {{ include "chat2db.fullname" . }}-deny-ingress + name: {{ include "chat2db.fullname" . }}-ingress labels: {{- include "chat2db.labels" . | nindent 4 }} spec: @@ -11,5 +11,19 @@ spec: {{- include "chat2db.selectorLabels" . | nindent 6 }} policyTypes: - Ingress + {{- with .Values.networkPolicy.allowedIngress }} + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .namespace | quote }} + podSelector: + matchLabels: + {{- toYaml .podLabels | nindent 14 }} + ports: + - protocol: TCP + port: 10825 + {{- else }} ingress: [] + {{- end }} {{- end }} diff --git a/charts/chat2db/values.yaml b/charts/chat2db/values.yaml index 8cfafac15e..d6e7045eb6 100644 --- a/charts/chat2db/values.yaml +++ b/charts/chat2db/values.yaml @@ -12,6 +12,9 @@ automountServiceAccountToken: false service: port: 10825 +cors: + allowedOrigin: "" + persistence: storageClassName: "" accessModes: @@ -37,3 +40,6 @@ affinity: {} networkPolicy: enabled: true + # Keep empty for default-deny ingress. A protected hosted deployment should + # name its central oauth2-proxy namespace and exact proxy workload labels. + allowedIngress: null diff --git a/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/config/web/interceptor/CorsFilter.java b/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/config/web/interceptor/CorsFilter.java index e9ec2ecd8d..721a1529cf 100644 --- a/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/config/web/interceptor/CorsFilter.java +++ b/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/config/web/interceptor/CorsFilter.java @@ -1,6 +1,9 @@ package ai.chat2db.community.web.api.config.web.interceptor; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashSet; import java.util.Set; import ai.chat2db.community.tools.util.ConfigUtils; @@ -11,6 +14,7 @@ import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; @@ -18,13 +22,19 @@ @Component public class CorsFilter implements Filter { - private static final Set COMMUNITY_ALLOWED_ORIGINS = Set.of( + private static final Set DEFAULT_COMMUNITY_ALLOWED_ORIGINS = Set.of( "http://127.0.0.1:8888", "http://localhost:8888", "http://127.0.0.1:10825", "http://localhost:10825" ); + private final Set communityAllowedOrigins; + + public CorsFilter(@Value("${chat2db.web.cors.allowed-origin:}") String configuredOrigin) { + this.communityAllowedOrigins = buildAllowedOrigins(configuredOrigin); + } + @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { @@ -36,15 +46,52 @@ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) response.sendError(HttpServletResponse.SC_FORBIDDEN); return; } - setCorsHeaders(response, origin); + if (origin != null) { + setCorsHeaders(response, origin); + } response.setHeader("Access-Control-Allow-Methods", "POST, GET, PUT, OPTIONS, DELETE"); response.setHeader("Access-Control-Max-Age", "3600"); response.setHeader("Access-Control-Allow-Headers", "Content-Type, DBHUB, uid, Time-Zone"); chain.doFilter(req, res); } - static boolean allowCommunityOrigin(String origin) { - return origin == null || origin.isBlank() || COMMUNITY_ALLOWED_ORIGINS.contains(origin); + boolean allowCommunityOrigin(String origin) { + return origin == null || communityAllowedOrigins.contains(origin); + } + + private static Set buildAllowedOrigins(String configuredOrigin) { + Set origins = new HashSet<>(DEFAULT_COMMUNITY_ALLOWED_ORIGINS); + if (configuredOrigin.isEmpty()) { + return Set.copyOf(origins); + } + + validateConfiguredOrigin(configuredOrigin); + origins.add(configuredOrigin); + return Set.copyOf(origins); + } + + private static void validateConfiguredOrigin(String origin) { + if (!origin.equals(origin.trim()) || origin.chars().anyMatch(Character::isWhitespace) || origin.contains(",")) { + throw new IllegalArgumentException("chat2db.web.cors.allowed-origin must be one canonical HTTPS origin"); + } + + try { + URI uri = new URI(origin); + if (!"https".equals(uri.getScheme()) + || uri.getHost() == null + || uri.getHost().isBlank() + || uri.getRawUserInfo() != null + || (uri.getRawPath() != null && !uri.getRawPath().isEmpty()) + || uri.getRawQuery() != null + || uri.getRawFragment() != null + || uri.getPort() > 65535) { + throw new IllegalArgumentException( + "chat2db.web.cors.allowed-origin must be one canonical HTTPS origin"); + } + } catch (URISyntaxException exception) { + throw new IllegalArgumentException( + "chat2db.web.cors.allowed-origin must be one canonical HTTPS origin", exception); + } } private static void setCorsHeaders(HttpServletResponse response, String origin) { diff --git a/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/config/config/interceptor/CorsFilterTest.java b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/config/config/interceptor/CorsFilterTest.java index b4815870fe..356f9333dc 100644 --- a/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/config/config/interceptor/CorsFilterTest.java +++ b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/config/config/interceptor/CorsFilterTest.java @@ -3,18 +3,69 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class CorsFilterTest { @Test - void communityAllowsOnlyKnownFrontendOriginsOrMissingOrigin() { - assertTrue(CorsFilter.allowCommunityOrigin(null)); - assertTrue(CorsFilter.allowCommunityOrigin("")); - assertTrue(CorsFilter.allowCommunityOrigin("http://127.0.0.1:8888")); - assertTrue(CorsFilter.allowCommunityOrigin("http://localhost:10825")); - - assertFalse(CorsFilter.allowCommunityOrigin("https://example.com")); - assertFalse(CorsFilter.allowCommunityOrigin("http://127.0.0.1:3000")); + void defaultsAllowLocalOriginsAndMissingOrigin() { + CorsFilter filter = new CorsFilter(""); + + assertTrue(filter.allowCommunityOrigin(null)); + assertTrue(filter.allowCommunityOrigin("http://127.0.0.1:8888")); + assertTrue(filter.allowCommunityOrigin("http://localhost:10825")); + + assertFalse(filter.allowCommunityOrigin("")); + assertFalse(filter.allowCommunityOrigin("https://example.com")); + assertFalse(filter.allowCommunityOrigin("http://127.0.0.1:3000")); + } + + @Test + void configuredHttpsOriginIsAllowedExactly() { + CorsFilter filter = new CorsFilter("https://chat2db.hazyforge.io"); + + assertTrue(filter.allowCommunityOrigin("https://chat2db.hazyforge.io")); + } + + @Test + void configuredOriginDoesNotAllowLookalikes() { + CorsFilter filter = new CorsFilter("https://chat2db.hazyforge.io"); + + assertFalse(filter.allowCommunityOrigin("http://chat2db.hazyforge.io")); + assertFalse(filter.allowCommunityOrigin("https://sub.chat2db.hazyforge.io")); + assertFalse(filter.allowCommunityOrigin("https://chat2db.hazyforge.io.attacker.example")); + assertFalse(filter.allowCommunityOrigin("https://chat2db.hazyforge.io:8443")); + assertFalse(filter.allowCommunityOrigin("https://chat2db.hazyforge.io/")); + assertFalse(filter.allowCommunityOrigin("https://CHAT2DB.hazyforge.io")); + } + + @Test + void blankRequestOriginIsRejected() { + CorsFilter filter = new CorsFilter("https://chat2db.hazyforge.io"); + + assertFalse(filter.allowCommunityOrigin("")); + assertFalse(filter.allowCommunityOrigin(" ")); + } + + @Test + void malformedConfiguredOriginFailsConstruction() { + String[] malformedOrigins = { + "http://chat2db.hazyforge.io", + "chat2db.hazyforge.io", + "https://chat2db.hazyforge.io/", + "https://chat2db.hazyforge.io/path", + "https://chat2db.hazyforge.io?query=true", + "https://chat2db.hazyforge.io#fragment", + "https://user@chat2db.hazyforge.io", + " https://chat2db.hazyforge.io", + "https://chat2db.hazyforge.io ", + "https://chat2db.hazyforge.io,https://attacker.example", + "https://chat2db.hazyforge.io:65536" + }; + + for (String origin : malformedOrigins) { + assertThrows(IllegalArgumentException.class, () -> new CorsFilter(origin), origin); + } } }