Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/hazyforge-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand All @@ -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
Expand Down
22 changes: 10 additions & 12 deletions HAZYFORGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions charts/chat2db/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion charts/chat2db/templates/networkpolicy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
6 changes: 6 additions & 0 deletions charts/chat2db/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ automountServiceAccountToken: false
service:
port: 10825

cors:
allowedOrigin: ""

persistence:
storageClassName: ""
accessModes:
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,20 +14,27 @@
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;


@Component
public class CorsFilter implements Filter {

private static final Set<String> COMMUNITY_ALLOWED_ORIGINS = Set.of(
private static final Set<String> 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<String> 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 {
Expand All @@ -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<String> buildAllowedOrigins(String configuredOrigin) {
Set<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading