Skip to content
Draft
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
2 changes: 2 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion packaging/helm/artifact-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packaging/helm/artifact-server/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packaging/helm/artifact-server/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
1 change: 1 addition & 0 deletions packaging/helm/artifact-server/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ configuration:
bootstrapAdministratorEmail: ""
applicationOrigin: ""
contentDomain: ""
allowSameSiteContent: false
postgresConnectionBudget: 22
postgresPoolSize: 10
requestLogSampleRate: 0.01
Expand Down
6 changes: 6 additions & 0 deletions scripts/verify-helm-chart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions src/lifecycle/runtime-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -832,9 +841,20 @@ function assertHttpsServiceUrl(
return Effect.void;
}

function parseAllowSameSiteContent(
environment: NodeJS.ProcessEnv,
): Effect.Effect<boolean, RuntimeConfigurationError> {
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<void, RuntimeConfigurationError> {
let origin: URL;
try {
Expand Down Expand Up @@ -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 (
Expand Down
121 changes: 121 additions & 0 deletions tests/conformance/cnt-003-same-site-content.test.ts
Original file line number Diff line number Diff line change
@@ -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<CommandResult> {
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<CommandResult> {
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(),
});
});
});
}