diff --git a/backend/src/workspaces/schema.ts b/backend/src/workspaces/schema.ts index 47beb0b9..acd635cd 100644 --- a/backend/src/workspaces/schema.ts +++ b/backend/src/workspaces/schema.ts @@ -276,6 +276,7 @@ function isSafeEvidencePattern(value: string): boolean { } function parsePublicHttpUri(value: string): URL | undefined { + if (value.trim() !== value || /[\u0000-\u001f\u007f\\]/u.test(value)) return undefined; try { const parsed = new URL(value); if ( @@ -297,14 +298,20 @@ function canonicalPublicHttpUri(value: string): string | undefined { } function isSafeS3Uri(value: string): boolean { + if (value.trim() !== value || /[\u0000-\u001f\u007f\\]/u.test(value)) return false; try { const parsed = new URL(value); + const bucket = parsed.hostname; + const validBucket = /^(?=.{3,63}$)(?!-)(?!.*\.\.)(?!.*\.-)(?!.*-\.)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(bucket) + && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bucket); return parsed.protocol === "s3:" - && parsed.hostname.length > 0 + && validBucket + && parsed.port === "" && parsed.username === "" && parsed.password === "" && parsed.search === "" - && parsed.hash === ""; + && parsed.hash === "" + && parsed.href === value; } catch { return false; } @@ -380,7 +387,8 @@ const s3EvidenceSourceSchema = z.object({ message: "custom S3 endpoints must be explicitly trusted", }); } - if (source.endpoint_url.startsWith("http:") && !source.allow_insecure_endpoint) { + const endpoint = parsePublicHttpUri(source.endpoint_url); + if (endpoint?.protocol === "http:" && !source.allow_insecure_endpoint) { context.addIssue({ code: "custom", path: ["allow_insecure_endpoint"], message: "HTTP S3 endpoints require an explicit insecure opt-in", diff --git a/backend/test/workspaces-schema.test.ts b/backend/test/workspaces-schema.test.ts index fa53bf81..2b6f971f 100644 --- a/backend/test/workspaces-schema.test.ts +++ b/backend/test/workspaces-schema.test.ts @@ -476,6 +476,7 @@ const invalidHttpUris = [ "https://example.com/manifest?token=CANARY-HTTP", "https://example.com/manifest#CANARY-HTTP", "ftp://example.com/manifest/CANARY-HTTP", + "\nhttps://example.com/CANARY-HTTP", ]; test.each(invalidHttpUris)("rejects unsafe HTTP descriptor URI %# without leaking it", (uri) => { @@ -506,6 +507,8 @@ test.each(invalidHttpBounds)("rejects invalid HTTP bound %s=%s", (field, value) const invalidS3Uris = [ "https://bucket/prefix", "s3:///prefix", "s3://user:CANARY-S3@bucket/prefix", "s3://bucket/prefix?token=CANARY-S3", "s3://bucket/prefix#CANARY-S3", + "s3://bucket:123/CANARY-S3", "s3://bucket/%2e%2e/CANARY-S3", + "s3://127.0.0.1/CANARY-S3", "s3://UPPERCASE/CANARY-S3", ]; test.each(invalidS3Uris)("rejects invalid S3 URI %# without leaking it", (uri) => { @@ -520,6 +523,7 @@ const invalidS3Endpoints = [ { endpoint_url: "https://objects.example#CANARY-S3", trusted_endpoint: true }, { endpoint_url: "https://objects.example", trusted_endpoint: false }, { endpoint_url: "http://objects.example", trusted_endpoint: true, allow_insecure_endpoint: false }, + { endpoint_url: "HTTP://objects.example", trusted_endpoint: true, allow_insecure_endpoint: false }, { trusted_endpoint: true }, { allow_private_endpoint: true }, { allow_insecure_endpoint: true },