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
53 changes: 41 additions & 12 deletions apps/api/src/http/url-validator.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import { assert, describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"
import { safeFetch, UrlValidationError, validateExternalUrl, validateExternalUrlSync } from "./url-validator"
import { Effect, Result } from "effect"
import {
safeFetch,
UrlValidationError,
validateExternalUrl,
validateExternalUrlResult,
} from "./url-validator"

describe("validateExternalUrlSync", () => {
/** Rejected, and rejected as the tagged error rather than by throwing. */
const rejects = (raw: string): void => {
const result = validateExternalUrlResult(raw)
assert.isTrue(Result.isFailure(result), `expected ${raw} to be rejected`)
if (Result.isFailure(result)) expect(result.failure).toBeInstanceOf(UrlValidationError)
}

const accepts = (raw: string): URL => {
const result = validateExternalUrlResult(raw)
assert.isTrue(Result.isSuccess(result), `expected ${raw} to be accepted`)
return Result.isSuccess(result) ? result.success : new URL("https://unreachable.invalid")
}

describe("validateExternalUrlResult", () => {
it("accepts public https URLs", () => {
const url = validateExternalUrlSync("https://api.example.com/probe")
const url = accepts("https://api.example.com/probe")
expect(url.hostname).toBe("api.example.com")
})

it("accepts public http URLs", () => {
const url = validateExternalUrlSync("http://prom.public.dev:9090/metrics")
const url = accepts("http://prom.public.dev:9090/metrics")
expect(url.hostname).toBe("prom.public.dev")
})

it.each(["javascript:alert(1)", "file:///etc/passwd", "ftp://example.com", "data:text/html,<script>"])(
"rejects non-http(s) scheme: %s",
(raw) => {
expect(() => validateExternalUrlSync(raw)).toThrow(UrlValidationError)
rejects(raw)
},
)

Expand Down Expand Up @@ -66,15 +84,15 @@ describe("validateExternalUrlSync", () => {
"http://239.255.255.250/",
"http://240.0.0.1/",
])("rejects private/loopback host: %s", (raw) => {
expect(() => validateExternalUrlSync(raw)).toThrow(UrlValidationError)
rejects(raw)
})

// The parser's host here is `internal`, not `real.example.com` — the credentials
// hide the real destination from anyone eyeballing the stored URL.
it.each(["https://real.example.com@localhost/", "https://user:pw@api.example.com/"])(
"rejects embedded credentials: %s",
(raw) => {
expect(() => validateExternalUrlSync(raw)).toThrow(UrlValidationError)
rejects(raw)
},
)

Expand All @@ -97,16 +115,16 @@ describe("validateExternalUrlSync", () => {
"https://127.acme.io/hook",
"https://192.168.example.com/hook",
])("accepts public host: %s", (raw) => {
expect(() => validateExternalUrlSync(raw)).not.toThrow()
accepts(raw)
})

it("rejects empty string", () => {
expect(() => validateExternalUrlSync("")).toThrow(UrlValidationError)
expect(() => validateExternalUrlSync(" ")).toThrow(UrlValidationError)
rejects("")
rejects(" ")
})

it("rejects malformed input", () => {
expect(() => validateExternalUrlSync("not a url")).toThrow(UrlValidationError)
rejects("not a url")
})
})

Expand Down Expand Up @@ -163,6 +181,17 @@ describe("safeFetch", () => {
expect(calls).toBe(1)
})

// The far end controls `Location`. Resolving it with a bare
// `new URL(location, validated)` threw a raw TypeError out of the guard, which
// is neither a rejection callers can handle nor a response.
it("rejects a redirect whose Location is not a URL", async () => {
const fakeFetch: typeof fetch = async () =>
new Response(null, { status: 302, headers: { location: "http://" } })
await expect(
safeFetch("https://api.example.com/x", { fetchFn: fakeFetch }),
).rejects.toBeInstanceOf(UrlValidationError)
})

it("follows a redirect to another public URL", async () => {
let calls = 0
const fakeFetch: typeof fetch = async (input) => {
Expand Down
87 changes: 53 additions & 34 deletions apps/api/src/http/url-validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Effect, Schema } from "effect"
import { Effect, Option, Result, Schema } from "effect"
import { parseUrl, parseUrlWithBase } from "@maple/domain/url"

export class UrlValidationError extends Schema.TaggedError<UrlValidationError>()(
"@maple/api/lib/UrlValidationError",
Expand Down Expand Up @@ -137,55 +138,60 @@ const isPrivateHost = (hostname: string): boolean => {
return false
}

export const validateExternalUrlSync = (raw: string): URL => {
/**
* The validation itself, as a value.
*
* Every rejection is a `UrlValidationError`, so the whole check reads as one
* `Result` rather than a throwing function wrapped in an `Effect.try` that has
* to re-recognise its own tagged error on the way back out. `parseUrl` is what
* keeps the parse total: `new URL(...)` in a `try` throws a `TypeError` the type
* system cannot see, and the `catch` answering it cannot tell "not a URL" from
* any other failure in the block.
*/
export const validateExternalUrlResult = (raw: string): Result.Result<URL, UrlValidationError> => {
const trimmed = raw.trim()
if (trimmed.length === 0) {
throw new UrlValidationError({ message: "URL is required" })
return Result.fail(new UrlValidationError({ message: "URL is required" }))
}
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
throw new UrlValidationError({ message: `Invalid URL: ${trimmed}`, url: trimmed })
const decoded = parseUrl(trimmed)
if (Option.isNone(decoded)) {
return Result.fail(new UrlValidationError({ message: `Invalid URL: ${trimmed}`, url: trimmed }))
}
const parsed = decoded.value
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new UrlValidationError({
message: `URL scheme '${parsed.protocol}' is not allowed; use http or https`,
url: trimmed,
})
return Result.fail(
new UrlValidationError({
message: `URL scheme '${parsed.protocol}' is not allowed; use http or https`,
url: trimmed,
}),
)
}
if (parsed.hostname.length === 0) {
throw new UrlValidationError({ message: "URL must include a hostname", url: trimmed })
return Result.fail(
new UrlValidationError({ message: "URL must include a hostname", url: trimmed }),
)
}
// Credentials in the URL are both a way to smuggle a second host past a
// reader (`https://real.example.com@internal/`, where the parser's host is
// `internal`) and a way to have Maple replay them at the destination.
if (parsed.username !== "" || parsed.password !== "") {
throw new UrlValidationError({
message: "URL must not embed credentials",
url: trimmed,
})
return Result.fail(
new UrlValidationError({ message: "URL must not embed credentials", url: trimmed }),
)
}
if (isPrivateHost(parsed.hostname)) {
throw new UrlValidationError({
message: `URL host '${parsed.hostname}' is not allowed (loopback, private, or metadata range)`,
url: trimmed,
})
return Result.fail(
new UrlValidationError({
message: `URL host '${parsed.hostname}' is not allowed (loopback, private, or metadata range)`,
url: trimmed,
}),
)
}
return parsed
return Result.succeed(parsed)
}

export const validateExternalUrl = (raw: string): Effect.Effect<URL, UrlValidationError> =>
Effect.try({
try: () => validateExternalUrlSync(raw),
catch: (error) =>
error instanceof UrlValidationError
? error
: new UrlValidationError({
message: error instanceof Error ? error.message : "URL validation failed",
url: raw,
}),
})
Effect.fromResult(validateExternalUrlResult(raw))

const MAX_REDIRECTS = 5

Expand Down Expand Up @@ -213,7 +219,9 @@ export const safeFetch = async (initialUrl: string, init: SafeFetchOptions = {})
let headers = init.headers
let previousOrigin: string | null = null
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const validated = validateExternalUrlSync(currentUrl)
const checked = validateExternalUrlResult(currentUrl)
if (Result.isFailure(checked)) throw checked.failure
const validated = checked.success
// A cross-origin hop drops the credentials for good: restoring them on a
// bounce back to the original origin would make the strip trivially
// bypassable by redirecting away and back again.
Expand All @@ -225,7 +233,18 @@ export const safeFetch = async (initialUrl: string, init: SafeFetchOptions = {})
if (response.status < 300 || response.status >= 400) return response
const location = response.headers.get("location")
if (!location) return response
currentUrl = new URL(location, validated).toString()
// `Location` is whatever the far end sent. Resolving it with a bare
// `new URL(location, validated)` threw a raw `TypeError` out of the SSRF
// guard on a malformed header — the one failure here that was neither a
// `UrlValidationError` nor a response.
const next = parseUrlWithBase(location, validated)
if (Option.isNone(next)) {
throw new UrlValidationError({
message: `Redirect target is not a URL: ${location}`,
url: validated.toString(),
})
}
currentUrl = next.value.toString()
}
throw new UrlValidationError({
message: `Too many redirects (>${MAX_REDIRECTS})`,
Expand Down
15 changes: 6 additions & 9 deletions apps/api/src/platform/WorkersAiHttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
*/
import { Effect, Layer, Predicate } from "effect"
import { HttpClient, HttpClientError, HttpClientResponse, type HttpClientRequest } from "effect/unstable/http"
import { urlPathname } from "@maple/domain/url"

/** The subset of the Cloudflare `Ai` binding this shim uses. */
export interface WorkersAiBinding {
Expand Down Expand Up @@ -52,15 +53,11 @@ export const isWorkersAiBinding = (value: unknown): value is WorkersAiBinding =>
* `.../accounts/{id}/ai/v1/chat/completions` form and an AI Gateway `.../compat/chat/completions`.
*/
const isWorkersAiChatUrl = (url: string): boolean => {
try {
const { pathname } = new URL(url)
return (
pathname.endsWith("/chat/completions") &&
(pathname.includes("/ai/v1/") || pathname.includes("/compat/"))
)
} catch {
return false
}
const pathname = urlPathname(url)
return (
pathname.endsWith("/chat/completions") &&
(pathname.includes("/ai/v1/") || pathname.includes("/compat/"))
)
}

const encodeError = (request: HttpClientRequest.HttpClientRequest, cause: unknown) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
import { parseBase64Aes256GcmKey } from "@/platform/Crypto"
import { Env } from "@/platform/Env"
import { buildScrapeAuthHeaders } from "@/services/auth/scrape-auth"
import { validateExternalUrlSync } from "@/http/url-validator"
import { validateExternalUrlResult } from "@/http/url-validator"
import { decodeDiscoveryConfig } from "./planetscale/discovery-config"
import {
PlanetScaleOAuthService,
Expand Down Expand Up @@ -143,9 +143,7 @@ export const subTargetsFromGroup = (group: {
const dropped: Array<string> = []
for (const hostPort of group.targets) {
const url = `${scheme}://${hostPort}${path}`
try {
validateExternalUrlSync(url)
} catch {
if (Result.isFailure(validateExternalUrlResult(url))) {
dropped.push(url)
continue
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
VcsWebhookSignatureError,
} from "@maple/domain/http"
import { Clock, Context, Effect, Layer, Match, Option, Redacted, Schema } from "effect"
import { parseUrlWithBase } from "@maple/domain/url"
import { Env } from "@/platform/Env"
import type { VcsProviderClient, VcsWebhookRequest } from "@/services/integrations/vcs/VcsProviderClient"
import { QUEUE_MESSAGE_LIMIT_BYTES } from "@/services/integrations/vcs/VcsSyncQueue"
Expand Down Expand Up @@ -206,11 +207,10 @@ const finiteOrNull = (value: number) => (Number.isFinite(value) ? value : null)
// dashboard renders with an initials fallback.
const githubAvatarUrl = (htmlUrl: string, login: string | null): string | null => {
if (!login) return null
try {
return new URL(`/${encodeURIComponent(login)}.png?size=64`, htmlUrl).href
} catch {
return null
}
return Option.match(parseUrlWithBase(`/${encodeURIComponent(login)}.png?size=64`, htmlUrl), {
onNone: () => null,
onSome: (url) => url.href,
})
}

const installationReason = (action: string): VcsInstallationSyncReason | null => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import { Option, Schema } from "effect"

/**
* A string to a `URL`, or `Option.none` when it is not one — the same shape and
* the same reasoning as `parsePullRequestUrl`: the schema reports the failure as
* a value rather than a thrown exception, and `decodeUnknownOption` keeps this
* synchronous and total, so it stays a plain function every caller can use.
*/
const decodeUrl = Schema.decodeUnknownOption(Schema.URLFromString)
import { Option } from "effect"
import { parseUrl } from "@maple/domain/url"

const PUBLIC_GITHUB_WEB = "https://github.com"

Expand All @@ -21,7 +14,7 @@ const PUBLIC_GITHUB_WEB = "https://github.com"
* GitHub splits the two across `api.github.com` and `github.com`.
*/
export const githubWebBaseUrl = (apiBaseUrl: string): string => {
const decoded = decodeUrl(apiBaseUrl.trim())
const decoded = parseUrl(apiBaseUrl.trim())
if (Option.isNone(decoded)) return PUBLIC_GITHUB_WEB
const url = decoded.value

Expand Down
29 changes: 7 additions & 22 deletions apps/api/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { MessageBatch, ScheduledController } from "@cloudflare/workers-types"
import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare"
import { ANTICIPATED_ERROR_IDENTIFIERS } from "@maple/domain/anticipated-errors"
import { urlPathname } from "@maple/domain/url"
import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "./mcp/expected-failures"
import {
layerFromEnvRecord,
Expand Down Expand Up @@ -231,22 +232,12 @@ const runInternalRpc = async (
throw new Error("RPC method failed with an unexpected cause")
}

const isMcpPost = (request: Request): boolean => {
if (request.method !== "POST") return false
try {
return new URL(request.url).pathname === "/mcp"
} catch {
return false
}
}
const isMcpPost = (request: Request): boolean =>
request.method === "POST" && urlPathname(request.url) === "/mcp"

const isV2Request = (request: Request): boolean => {
try {
const pathname = new URL(request.url).pathname
return pathname === "/v2" || pathname.startsWith("/v2/")
} catch {
return false
}
const pathname = urlPathname(request.url)
return pathname === "/v2" || pathname.startsWith("/v2/")
}

/**
Expand All @@ -255,14 +246,8 @@ const isV2Request = (request: Request): boolean => {
* bootstrap-safe also lets a cold isolate report health when an unrelated
* application binding is unavailable.
*/
const isHealthRequest = (request: Request): boolean => {
if (request.method !== "GET") return false
try {
return new URL(request.url).pathname === "/health"
} catch {
return false
}
}
const isHealthRequest = (request: Request): boolean =>
request.method === "GET" && urlPathname(request.url) === "/health"

const healthResponse = (): Response =>
new Response("OK", {
Expand Down
1 change: 1 addition & 0 deletions packages/domain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"./recommendations": "./src/recommendations.ts",
"./setup-audit": "./src/setup-audit.ts",
"./system-agents": "./src/system-agents.ts",
"./url": "./src/url.ts",
"./tinybird-project-sync": "./src/tinybird/project-sync.ts",
"./warehouse-queries": "./src/warehouse-queries.ts",
"./tinybird": "./src/tinybird/index.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray<string> = [
"@maple/http/errors/ErrorIssuePullRequestNotFoundError",
"@maple/http/errors/ErrorIssueTransitionError",
"@maple/http/errors/ErrorValidationError",
"@maple/http/errors/IngestAttributeMappingForbiddenError",
"@maple/http/errors/IngestAttributeMappingNotFoundError",
"@maple/http/errors/IngestAttributeMappingValidationError",
"@maple/http/errors/IntegrationsForbiddenError",
Expand Down
Loading
Loading