Harden cloud upstream auth URLs

This commit is contained in:
Dotta committed 2026-05-19 15:24:54 -05:00
1 parent beebfbc723
commit ca7a55e530
6 files changed
+67 -9

No files matched your search

+10
View File
@@ -8,6 +8,7 @@ import {
buildBundleFromLocalCompany,
cloudCommandExitCodes,
connectCloud,
discoverUpstream,
} from "../commands/client/cloud.js";
import {
LocalUpstreamPushCoordinator,
@@ -88,6 +89,15 @@ describe("cloud CLI helpers", () => {
expect(cloudCommandExitCodes.schemaMismatch).toBe(3);
});
it("requires HTTPS before fetching cloud discovery except for localhost development", async () => {
globalThis.fetch = vi.fn(async () => {
throw new Error("discovery fetch should not run for insecure remote URLs");
}) as typeof fetch;
await expect(discoverUpstream("http://cloud.example.test")).rejects.toThrow(/must use HTTPS/i);
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("builds deterministic chunks with validated payload hashes", async () => {
const bundle = await buildTestBundle();
+18
View File
@@ -239,6 +239,7 @@ export async function pushCloud(opts: CloudPushOptions): Promise<unknown> {
export async function discoverUpstream(remoteUrl: string): Promise<UpstreamDiscovery> {
const base = new URL(remoteUrl);
assertSecureCloudUpstreamUrl(base, "Cloud upstream remote URL");
const discoveryUrl = new URL("/.well-known/paperclip-upstream", base);
return requestCloudJson<UpstreamDiscovery>(discoveryUrl.toString(), { method: "GET" });
}
@@ -347,6 +348,8 @@ async function authorizeWithBrowser(
): Promise<TokenResponse> {
const pkce = discovery.auth.pkce;
if (!pkce) throw new Error("Remote did not advertise PKCE authorization.");
assertSecureCloudUpstreamUrl(pkce.authorizeUrl, "Cloud upstream authorize URL");
assertSecureCloudUpstreamUrl(pkce.tokenUrl, "Cloud upstream token URL");
const callback = await startPkceCallbackServer();
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
@@ -388,6 +391,8 @@ async function authorizeWithDeviceCode(
): Promise<TokenResponse> {
const device = discovery.auth.deviceCode;
if (!device) throw new Error("Remote did not advertise device-code authorization.");
assertSecureCloudUpstreamUrl(device.deviceCodeUrl, "Cloud upstream device-code URL");
assertSecureCloudUpstreamUrl(device.tokenUrl, "Cloud upstream token URL");
const response = await requestCloudJson<{
deviceCode: string;
userCode: string;
@@ -407,6 +412,7 @@ async function authorizeWithDeviceCode(
console.error(pc.bold("Cloud device authorization required"));
console.error(`Open: ${response.verificationUri}`);
console.error(`Code: ${response.userCode}`);
assertSecureCloudUpstreamUrl(response.verificationUri, "Cloud upstream verification URL");
if (opts.openBrowser) openUrl(response.verificationUri);
const expiresAt = Date.parse(response.expiresAt);
@@ -435,6 +441,18 @@ async function authorizeWithDeviceCode(
throw new Error("Device-code authorization expired before it was approved.");
}
function assertSecureCloudUpstreamUrl(url: string | URL, label: string): void {
const parsed = typeof url === "string" ? new URL(url) : url;
if (parsed.protocol === "https:" || isLocalhostHost(parsed.hostname)) {
return;
}
throw new Error(`${label} must use HTTPS except for localhost development.`);
}
function isLocalhostHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
}
function buildEntitiesFromPortableExport(
localCompanyId: string,
sourceInstanceId: string,
@@ -66,6 +66,11 @@ DO $$ BEGIN
ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_connections_last_run_id_cloud_upstream_runs_id_fk') THEN
ALTER TABLE "cloud_upstream_connections" ADD CONSTRAINT "cloud_upstream_connections_last_run_id_cloud_upstream_runs_id_fk" FOREIGN KEY ("last_run_id") REFERENCES "public"."cloud_upstream_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cloud_upstream_connections_company_idx" ON "cloud_upstream_connections" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_company_created_idx" ON "cloud_upstream_runs" USING btree ("company_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_connection_idx" ON "cloud_upstream_runs" USING btree ("connection_id");
+2 -2
View File
@@ -1,4 +1,4 @@
import { boolean, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { type AnyPgColumn, boolean, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const cloudUpstreamConnections = pgTable(
@@ -33,7 +33,7 @@ export const cloudUpstreamConnections = pgTable(
pendingRedirectUri: text("pending_redirect_uri"),
pendingTokenUrl: text("pending_token_url"),
lastRunId: uuid("last_run_id"),
lastRunId: uuid("last_run_id").references((): AnyPgColumn => cloudUpstreamRuns.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
+10 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { HttpError } from "../errors.js";
import { cloudUpstreamRemoteFailureReport } from "../services/cloud-upstreams.js";
import { cloudUpstreamRemoteFailureReport, parseSecureCloudUpstreamUrl } from "../services/cloud-upstreams.js";
describe("cloud upstream remote failures", () => {
it("preserves the cloud response body and message on run reports", () => {
@@ -49,4 +49,13 @@ describe("cloud upstream remote failures", () => {
error: "network failed",
});
});
it("requires HTTPS for credential-bearing cloud URLs except localhost development", () => {
expect(parseSecureCloudUpstreamUrl("https://cloud.example.test/token", "Cloud upstream token URL").origin)
.toBe("https://cloud.example.test");
expect(parseSecureCloudUpstreamUrl("http://localhost:3100/token", "Cloud upstream token URL").origin)
.toBe("http://localhost:3100");
expect(() => parseSecureCloudUpstreamUrl("http://cloud.example.test/token", "Cloud upstream token URL"))
.toThrow(/must use HTTPS/i);
});
});
+22 -6
View File
@@ -558,7 +558,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
} : {}),
events: [
...input.initialEvents,
event(failedAt.toISOString(), "push", "failed", failure.errorMessage ?? failure.error),
event(failedAt.toISOString(), currentStep, "failed", failure.errorMessage ?? failure.error),
],
report: {
runId: input.runId,
@@ -695,10 +695,7 @@ export function cloudUpstreamService(db: Db, options: { instanceId?: string } =
}
async function fetchDiscovery(remoteUrl: string): Promise<Record<string, unknown>> {
const parsed = new URL(remoteUrl);
if (parsed.protocol !== "https:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
throw badRequest("Cloud upstream targets require HTTPS except localhost development");
}
const parsed = parseSecureCloudUpstreamUrl(remoteUrl, "Cloud upstream target URL");
const stackId = firstPathSegment(parsed.pathname);
const discoveryUrl = new URL("/.well-known/paperclip-upstream", parsed.origin);
if (stackId) {
@@ -809,7 +806,26 @@ function consentUrlFromDiscovery(discovery: Record<string, unknown>): string {
}
function tokenUrlFromDiscovery(discovery: Record<string, unknown>): string {
return stringField(objectField(objectField(discovery, "auth"), "pkce"), "tokenUrl");
const url = stringField(objectField(objectField(discovery, "auth"), "pkce"), "tokenUrl");
parseSecureCloudUpstreamUrl(url, "Cloud upstream token URL");
return url;
}
export function parseSecureCloudUpstreamUrl(url: string, label: string): URL {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw badRequest(`${label} must be a valid URL`);
}
if (parsed.protocol === "https:" || isLocalhostHost(parsed.hostname)) {
return parsed;
}
throw badRequest(`${label} must use HTTPS except localhost development`);
}
function isLocalhostHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
}
function buildWarnings(schemaMajor: number): CloudUpstreamWarning[] {