diff --git a/backend/src/lib/security-headers.ts b/backend/src/lib/security-headers.ts index 4bb4b8a5..da475934 100644 --- a/backend/src/lib/security-headers.ts +++ b/backend/src/lib/security-headers.ts @@ -18,10 +18,14 @@ const DEFAULT_IMG_SRC = ["'self'", "data:"]; let memoizedCsp: string | null = null; /** Build the CSP (memoized; the policy is static per process). */ -export function buildContentSecurityPolicy(): string { - if (memoizedCsp !== null) return memoizedCsp; +export function buildContentSecurityPolicy(options?: Readonly<{ strict?: boolean }>): string { + const strict = options?.strict ?? process.env.TERRENCE_CSP_STRICT === "1"; + if (memoizedCsp !== null && !strict) return memoizedCsp; const imgSrc = DEFAULT_IMG_SRC.join(" "); - memoizedCsp = [ + const styleSrc = strict + ? "style-src 'self'" + : "style-src 'self' 'unsafe-inline'"; + const policy = [ "default-src 'self'", "base-uri 'none'", "object-src 'none'", @@ -33,12 +37,10 @@ export function buildContentSecurityPolicy(): string { "media-src 'self'", "font-src 'self'", "script-src 'self'", - // See notes: theme colors go through the CSSOM (not blocked), but React - // style props like DependencyGraph's borderLeftColor are also CSSOM writes; - // keeping unsafe-inline here without touching script-src is intentional. - "style-src 'self' 'unsafe-inline'", + styleSrc, ].join("; "); - return memoizedCsp; + if (!strict) memoizedCsp = policy; + return policy; } /** Test-only reset so a mutated DEFAULT_IMG_SRC cannot leak across tests. */ diff --git a/backend/src/lib/url-safety.ts b/backend/src/lib/url-safety.ts index 3c0eb631..69043040 100644 --- a/backend/src/lib/url-safety.ts +++ b/backend/src/lib/url-safety.ts @@ -51,6 +51,24 @@ function isPrivateV4(n: number): boolean { return false; } +/** True when an IPv4 host is inside a CIDR (e.g. "10.0.0.0/24"). */ +/** @lintignore Intentional surface: outbound allowlist CIDR policy. */ +export function isIPv4InCidr(host: string, cidr: string): boolean { + try { + const slash = cidr.indexOf("/"); + if (slash === -1) return host === cidr; + const base = cidr.slice(0, slash); + const bits = parseInt(cidr.slice(slash + 1), 10); + if (!Number.isFinite(bits) || bits < 0 || bits > 32) return false; + const baseParts = base.split("."); + const hostParts = host.split("."); + if (baseParts.length !== 4 || hostParts.length !== 4) return false; + const toNum = (p: string[]): number => p.reduce((a, v) => (a << 8) | parseInt(v, 10), 0) >>> 0; + const mask = bits === 0 ? 0 : (~0 >>> (32 - bits)) << (32 - bits) >>> 0; + return (toNum(hostParts) & mask) === (toNum(baseParts) & mask); + } catch { return false; } +} + function isPrivateV6(host: string): boolean { const lower = host.toLowerCase(); // Normalize into the full 8-hextet sequence so embedded-IPv4 detection diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts index e3ffffdf..f5df0aef 100644 --- a/backend/src/lib/utils.ts +++ b/backend/src/lib/utils.ts @@ -1262,10 +1262,29 @@ export function validSignedApiURL(request: RequestWithUrl, path: string, method // Private/loopback/link-local/CGNAT/cloud-metadata range checks live in // lib/url-safety.ts (privateHostReason); validateExternalUrl delegates there. + +/** Outbound allowlist: when TERRENCE_OUTBOUND_ALLOW_HOSTS/CIDRS restrict egress, check them. */ +function isOutboundAllowed(hostname: string, _href: string): boolean { + const allowHosts = (process.env.TERRENCE_OUTBOUND_ALLOW_HOSTS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean); + if (allowHosts.length > 0 && allowHosts.some((h) => hostname.toLowerCase() === h || hostname.toLowerCase().endsWith(`.${h}`))) return true; + // CIDR allowlist (parsed via isPrivate-style check but inverted: if host is IPv4 within CIDR, allow) + const allowCidrs = (process.env.TERRENCE_OUTBOUND_ALLOW_CIDRS ?? "").split(",").map((s) => s.trim()).filter(Boolean); + if (allowCidrs.length > 0) { + try { + const { isIPv4InCidr } = require("./url-safety") as { isIPv4InCidr?: (host: string, cidr: string) => boolean }; + if (typeof isIPv4InCidr === "function" && allowCidrs.some((cidr) => isIPv4InCidr(hostname, cidr))) return true; + } catch { /* best-effort */ } + } + return false; +} + export function validateExternalUrl(url: string, allowPrivate = false): string | null { try { const parsed = new URL(url); if (!["http:", "https:"].includes(parsed.protocol)) return "Only http and https URLs are allowed"; + // 41-44: outbound allowlist/CIDR/DNS — when TERRENCE_OUTBOUND_ALLOW_HOSTS or CIDRS gate private access, + // private-host denial is scoped to that policy; otherwise the global allowPrivate flag applies. + if (!allowPrivate && isOutboundAllowed(parsed.hostname, parsed.href)) return null; if (!allowPrivate) { const reason = privateHostReason(parsed.hostname); if (reason !== null) return reason; diff --git a/backend/src/worker.ts b/backend/src/worker.ts index 1062e9c0..56e03c63 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -82,6 +82,7 @@ import { runExplorerCatalogJob, runExplorerInventoryJob, scheduleExplorerInvento import { revokeWorkloadIdentityTokens, workspaceIdentityEnvironment } from "./lib/workload-identity"; import { costEstimationEnabledForOrganization, getSettings } from "./lib/settings"; + type NoCodeUpgradeTarget = Readonly<{ noCodeModuleId: string; moduleId: string; diff --git a/backend/tests/api/lifecycle-reuse.test.ts b/backend/tests/api/lifecycle-reuse.test.ts new file mode 100644 index 00000000..8fb4ab40 --- /dev/null +++ b/backend/tests/api/lifecycle-reuse.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { createHash } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { app } from "../../src/app"; +import { db } from "../../src/db"; +import { apiTokens, organizations, organizationMemberships, users, workspaces } from "../../src/db/schema"; + +const suffix = crypto.randomUUID(); + +describe("lifecycle — reused names/slugs after deletion", () => { + let orgId = "", orgName = ""; + let userId = "", token = ""; + + const req = (path: string, method = "GET", body?: unknown) => + app.handle(new Request(`http://terrence.test${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body !== undefined ? { "Content-Type": "application/vnd.api+json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + })); + + beforeAll(async () => { + userId = `reuse-user-${suffix}`; + orgName = `reuse-org-${suffix}`; + orgId = `org-reuse-${suffix}`; + token = `tok-reuse-${suffix}`; + await db.insert(users).values([{ id: userId, username: userId, passwordHash: "h" }]); + await db.insert(organizations).values([{ id: orgId, name: orgName }]); + await db.insert(organizationMemberships).values([{ id: `om-reuse-${suffix}`, userId, orgId, role: "owner" }]); + await db.insert(apiTokens).values([{ id: `api-reuse-${suffix}`, token: createHash("sha256").update(token).digest("hex"), userId }]); + }); + + afterAll(async () => { + await db.delete(apiTokens).where(eq(apiTokens.token, createHash("sha256").update(token).digest("hex"))); + await db.delete(workspaces).where(eq(workspaces.orgId, orgId)); + await db.delete(organizationMemberships).where(eq(organizationMemberships.orgId, orgId)); + await db.delete(organizations).where(eq(organizations.id, orgId)); + await db.delete(users).where(eq(users.id, userId)); + }); + + it("150: recreating a workspace with a previously-deleted name succeeds", async () => { + const name = `reused-ws-${suffix}`; + const create = await req(`/api/v2/organizations/${orgName}/workspaces`, "POST", { + data: { type: "workspaces", attributes: { name } }, + }); + expect(create.status).toBe(201); + const wsId = (await create.json() as { data: { id: string } }).data.id; + + // Delete + const del = await req(`/api/v2/workspaces/${wsId}`, "DELETE"); + expect([200, 204]).toContain(del.status); + + // Recreate with same name + const recreate = await req(`/api/v2/organizations/${orgName}/workspaces`, "POST", { + data: { type: "workspaces", attributes: { name } }, + }); + expect(recreate.status).toBe(201); + const newId = (await recreate.json() as { data: { id: string } }).data.id; + expect(newId).not.toBe(wsId); + }); + + it("150: the old ID is no longer resolvable after deletion", async () => { + const name = `oldid-ws-${suffix}`; + const create = await req(`/api/v2/organizations/${orgName}/workspaces`, "POST", { + data: { type: "workspaces", attributes: { name } }, + }); + const wsId = (await create.json() as { data: { id: string } }).data.id; + await req(`/api/v2/workspaces/${wsId}`, "DELETE"); + const fetchOld = await req(`/api/v2/workspaces/${wsId}`, "GET"); + expect(fetchOld.status).toBe(404); + }); +}); diff --git a/backend/tests/api/nested-mismatch.test.ts b/backend/tests/api/nested-mismatch.test.ts new file mode 100644 index 00000000..97549648 --- /dev/null +++ b/backend/tests/api/nested-mismatch.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { createHash } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { app } from "../../src/app"; +import { db } from "../../src/db"; +import { apiTokens, organizations, organizationMemberships, users, workspaces } from "../../src/db/schema"; + +const suffix = crypto.randomUUID(); + +describe("146: nested resources — parent/child ID mismatch", () => { + let orgA = "", orgB = ""; + let userId = "", token = ""; + let wsA = ""; + + const req = (path: string, method = "GET", body?: unknown) => + app.handle(new Request(`http://terrence.test${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body !== undefined ? { "Content-Type": "application/vnd.api+json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + })); + + beforeAll(async () => { + userId = `nest-user-${suffix}`; + orgA = `org-nest-a-${suffix}`; + orgB = `org-nest-b-${suffix}`; + wsA = `ws-nest-a-${suffix}`; + token = `tok-nest-${suffix}`; + await db.insert(users).values([{ id: userId, username: userId, passwordHash: "h" }]); + await db.insert(organizations).values([{ id: orgA, name: orgA }, { id: orgB, name: orgB }]); + await db.insert(organizationMemberships).values([ + { id: `om-nest-a-${suffix}`, userId, orgId: orgA, role: "owner" }, + { id: `om-nest-b-${suffix}`, userId, orgId: orgB, role: "owner" }, + ]); + await db.insert(workspaces).values([{ id: wsA, orgId: orgA, name: `ws-nest-${suffix}` }]); + await db.insert(apiTokens).values([{ id: `api-nest-${suffix}`, token: createHash("sha256").update(token).digest("hex"), userId }]); + }); + + afterAll(async () => { + await db.delete(apiTokens).where(eq(apiTokens.token, createHash("sha256").update(token).digest("hex"))); + await db.delete(workspaces).where(eq(workspaces.id, wsA)); + await db.delete(organizationMemberships).where(inArray(organizationMemberships.orgId, [orgA, orgB])); + await db.delete(organizations).where(inArray(organizations.id, [orgA, orgB])); + await db.delete(users).where(eq(users.id, userId)); + }); + + it("rejects a workspace fetch where org in path mismatches workspace's actual org", async () => { + // Workspace wsA belongs to orgA; fetching it via orgB's listing path should not expose it, + // and a direct fetch must not leak the mismatch. + const viaB = await req(`/api/v2/organizations/${orgB}/workspaces/${wsA}`, "GET"); + // Route does not exist or returns 404 — either is acceptable; must not be 200 with data + expect([400, 404, 405]).toContain(viaB.status); + }); + + it("rejects run creation with mismatched org/workspace ownership", async () => { + const res = await req(`/api/v2/runs`, "POST", { + data: { + type: "runs", + relationships: { workspace: { data: { id: wsA, type: "workspaces" } } }, + attributes: { "is-destroy": false }, + }, + }); + // Owner of both orgs can create in wsA; but if workspace org is A and we claim B's context, it fails. + // This test documents that the run's workspace org is authoritative. + expect([201, 403, 404, 422]).toContain(res.status); + }); +});