diff --git a/docs/deployment.md b/docs/deployment.md index 873fcbdb..c2a7f26d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -27,6 +27,8 @@ Each remote deployment needs these boundaries: The application origin serves Artifact Server, its API, and its MCP endpoint. The content domain serves untrusted artifact files. +The server refuses to start when both use the same registrable domain, because artifact JavaScript would then be same-site with the application. A private-network installation that accepts this risk can set `ARTIFACT_SERVER_ALLOW_SAME_SITE_CONTENT=true`. Artifact JavaScript can then set cookies for, and send same-site requests to, the application and every other host on that domain. + ## Select storage Compact Compose uses one SQLite database and one file volume. Run only one application process with this data layer. diff --git a/packaging/helm/artifact-server/README.md b/packaging/helm/artifact-server/README.md index db6b055d..3d2e2210 100644 --- a/packaging/helm/artifact-server/README.md +++ b/packaging/helm/artifact-server/README.md @@ -104,7 +104,9 @@ The Service is private `ClusterIP`. Choose one routing path: Both `artifacts.example.com` and `*.content.example.net` route to the same Service. An enabled Ingress requires the application origin to have no path or explicit port. The two hosts remain separate registrable domains for browser -isolation. +isolation. A private-network installation that accepts same-site artifact +content can set `configuration.allowSameSiteContent: true`; see the +[deployment guide](../../../docs/deployment.md). ## Workload identity diff --git a/packaging/helm/artifact-server/templates/_helpers.tpl b/packaging/helm/artifact-server/templates/_helpers.tpl index bb50b641..a5a7f890 100644 --- a/packaging/helm/artifact-server/templates/_helpers.tpl +++ b/packaging/helm/artifact-server/templates/_helpers.tpl @@ -139,6 +139,8 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end -}} {{- define "artifact-server.runtimeEnvironment" -}} +- name: ARTIFACT_SERVER_ALLOW_SAME_SITE_CONTENT + value: {{ .Values.configuration.allowSameSiteContent | quote }} - name: ARTIFACT_SERVER_API_TOKEN_FILE value: /run/secrets/artifact-server/api-token - name: ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL diff --git a/packaging/helm/artifact-server/values.schema.json b/packaging/helm/artifact-server/values.schema.json index 7f459499..e5e6d6e8 100644 --- a/packaging/helm/artifact-server/values.schema.json +++ b/packaging/helm/artifact-server/values.schema.json @@ -73,6 +73,7 @@ "bootstrapAdministratorEmail": {"type": "string", "format": "email"}, "applicationOrigin": {"type": "string", "pattern": "^https://[^/\\s]+$"}, "contentDomain": {"type": "string", "pattern": "^[A-Za-z0-9.-]+$"}, + "allowSameSiteContent": {"type": "boolean"}, "postgresConnectionBudget": {"type": "integer", "minimum": 1, "maximum": 100000}, "postgresPoolSize": {"type": "integer", "minimum": 1, "maximum": 100}, "requestLogSampleRate": {"type": "number", "minimum": 0, "maximum": 1}, diff --git a/packaging/helm/artifact-server/values.yaml b/packaging/helm/artifact-server/values.yaml index 258c6f86..12239c53 100644 --- a/packaging/helm/artifact-server/values.yaml +++ b/packaging/helm/artifact-server/values.yaml @@ -16,6 +16,7 @@ configuration: bootstrapAdministratorEmail: "" applicationOrigin: "" contentDomain: "" + allowSameSiteContent: false postgresConnectionBudget: 22 postgresPoolSize: 10 requestLogSampleRate: 0.01 diff --git a/scripts/verify-helm-chart.sh b/scripts/verify-helm-chart.sh index 1063b7d4..599a0b75 100755 --- a/scripts/verify-helm-chart.sh +++ b/scripts/verify-helm-chart.sh @@ -35,6 +35,12 @@ helm template artifact-server "$artifactserver_chart" \ --set ingress.applicationTlsSecretName=artifact-server-application-tls \ --set ingress.contentTlsSecretName=artifact-server-content-tls >/dev/null +helm template artifact-server "$artifactserver_chart" \ + --kube-version 1.36.0 \ + "${artifactserver_values[@]}" \ + --set configuration.contentDomain=content.artifacts.example.test \ + --set configuration.allowSameSiteContent=true >/dev/null + expect_render_failure() { local artifactserver_expected=$1 shift diff --git a/src/lifecycle/runtime-configuration.ts b/src/lifecycle/runtime-configuration.ts index 7cb319a4..43afa051 100644 --- a/src/lifecycle/runtime-configuration.ts +++ b/src/lifecycle/runtime-configuration.ts @@ -64,6 +64,7 @@ const postgresUrlSchema = Schema.String.check( Schema.isPattern(/^postgres(?:ql)?:\/\/[^\s]+$/u), ); const linkedFilesModeSchema = Schema.Literals(["off", "on"]); +const allowSameSiteContentSchema = Schema.Literals(["false", "true"]); const urlStringSchema = Schema.String.check( Schema.isPattern(/^https?:\/\/[^\s]+$/u), ); @@ -232,7 +233,11 @@ export const parseCompactRuntimeConfiguration = Effect.fn( input.environment["ARTIFACT_SERVER_CONTENT_DOMAIN"], hostnameSchema, ); - yield* assertBrowserIsolation(applicationOrigin, contentDomain); + yield* assertBrowserIsolation( + applicationOrigin, + contentDomain, + yield* parseAllowSameSiteContent(input.environment), + ); yield* ensureWritableDirectory(layout.dataDirectory); return { apiToken, @@ -284,7 +289,11 @@ export const parseExternalStorageRuntimeConfiguration = Effect.fn( environment["ARTIFACT_SERVER_CONTENT_DOMAIN"], hostnameSchema, ); - yield* assertBrowserIsolation(applicationOrigin, contentDomain); + yield* assertBrowserIsolation( + applicationOrigin, + contentDomain, + yield* parseAllowSameSiteContent(environment), + ); yield* assertLinkedFilesAreLocalOnly(yield* parseLinkedFilesMode(environment)); const configuredObjectStorage = yield* parseObjectStorage(environment); const apiToken = yield* readSecret( @@ -832,9 +841,20 @@ function assertHttpsServiceUrl( return Effect.void; } +function parseAllowSameSiteContent( + environment: NodeJS.ProcessEnv, +): Effect.Effect { + return parseRequiredEnvironment( + "ARTIFACT_SERVER_ALLOW_SAME_SITE_CONTENT", + environment["ARTIFACT_SERVER_ALLOW_SAME_SITE_CONTENT"] ?? "false", + allowSameSiteContentSchema, + ).pipe(Effect.map((value) => value === "true")); +} + function assertBrowserIsolation( applicationOrigin: string, contentDomain: string, + allowSameSiteContent: boolean, ): Effect.Effect { let origin: URL; try { @@ -863,6 +883,8 @@ function assertBrowserIsolation( "invalid_origin", ); } + // Operators of private networks can accept same-site artifact content. + if (allowSameSiteContent) return Effect.void; const applicationDomain = getDomain(origin.hostname, {allowPrivateDomains: true}); const publishedDomain = getDomain(contentDomain, {allowPrivateDomains: true}); if ( diff --git a/tests/conformance/cnt-003-same-site-content.test.ts b/tests/conformance/cnt-003-same-site-content.test.ts new file mode 100644 index 00000000..cd12e485 --- /dev/null +++ b/tests/conformance/cnt-003-same-site-content.test.ts @@ -0,0 +1,121 @@ +import {spawn} from "node:child_process"; +import {mkdtemp, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import path from "node:path"; + +import {afterAll, beforeAll, describe, expect, test} from "vitest"; + +import { + startStubOidcProvider, + type RunningStubOidcProvider, +} from "../support/stub-oidc-provider.js"; + +const repositoryRoot = path.resolve(import.meta.dirname, "../.."); +const sameSiteEnvironment = { + ARTIFACT_SERVER_CONTENT_DOMAIN: "content.example.com", + ARTIFACT_SERVER_ORIGIN: "https://artifacts.example.com", +}; + +interface CommandResult { + readonly exitCode: number; + readonly output: string; +} + +describe("same-site content domain", () => { + let compactDirectory: string; + let dataDirectory: string; + let provider: RunningStubOidcProvider; + + beforeAll(async () => { + compactDirectory = await mkdtemp( + path.join(tmpdir(), "artifact-server-same-site-content-"), + ); + dataDirectory = path.join(compactDirectory, "data"); + provider = await startStubOidcProvider(); + const initialized = await runCli([ + "init", + "--admin-email", + "administrator@example.test", + "--data", + dataDirectory, + ], {}); + if (initialized.exitCode !== 0) { + throw new Error(`Compact initialization failed: ${initialized.output}`); + } + }); + + afterAll(async () => { + await provider.stop(); + await rm(compactDirectory, {force: true, recursive: true}); + }); + + test("CNT-003-F: a same-site content domain is rejected unless the operator explicitly accepts it", async () => { + const rejected = await checkConfiguration(sameSiteEnvironment); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.output).toContain("different registrable domains"); + + const malformed = await checkConfiguration({ + ...sameSiteEnvironment, + ARTIFACT_SERVER_ALLOW_SAME_SITE_CONTENT: "yes", + }); + expect(malformed.exitCode).not.toBe(0); + expect(malformed.output).toContain("ARTIFACT_SERVER_ALLOW_SAME_SITE_CONTENT"); + + const accepted = await checkConfiguration({ + ...sameSiteEnvironment, + ARTIFACT_SERVER_ALLOW_SAME_SITE_CONTENT: "true", + }); + expect(accepted.output).not.toContain("different registrable domains"); + expect(accepted.exitCode).toBe(0); + }); + + function checkConfiguration( + environment: NodeJS.ProcessEnv, + ): Promise { + return runCli([ + "config", + "check", + "--mode", + "compact", + "--data", + dataDirectory, + ], { + ARTIFACT_SERVER_OIDC_CLIENT_ID: provider.clientId, + ARTIFACT_SERVER_OIDC_ISSUER: provider.issuer, + ...environment, + }); + } +}); + +function runCli( + commandArguments: readonly string[], + environment: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + path.join(repositoryRoot, "node_modules/.bin/tsx"), + [path.join(repositoryRoot, "src/cli/main.ts"), ...commandArguments], + { + cwd: repositoryRoot, + env: { + ...process.env, + ARTIFACT_SERVER_CONTENT_DOMAIN: "content.example.net", + ARTIFACT_SERVER_ORIGIN: "https://artifacts.example.com", + ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE: "0", + ...environment, + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const output: Uint8Array[] = []; + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); + child.on("error", reject); + child.on("close", (exitCode) => { + resolve({ + exitCode: exitCode ?? -1, + output: Buffer.concat(output).toString("utf8").trim(), + }); + }); + }); +}