Harden API route authorization boundaries (#4122)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The REST API is the control-plane boundary for companies, agents,
plugins, adapters, costs, invites, and issue mutations.
> - Several routes still relied on broad board or company access checks
without consistently enforcing the narrower actor, company, and
active-checkout boundaries those operations require.
> - That can allow agents or non-admin users to mutate sensitive
resources outside the intended governance path.
> - This pull request hardens the route authorization layer and adds
regression coverage for the audited API surfaces.
> - The benefit is tighter multi-company isolation, safer plugin and
adapter administration, and stronger enforcement of active issue
ownership.

## What Changed

- Added route-level authorization checks for budgets, plugin
administration/scoped routes, adapter management, company import/export,
direct agent creation, invite test resolution, and issue mutation/write
surfaces.
- Enforced active checkout ownership for agent-authenticated issue
mutations, while preserving explicit management overrides for permitted
managers.
- Restricted sensitive adapter and plugin management operations to
instance-admin or properly scoped actors.
- Tightened company portability and invite probing routes so agents
cannot cross company boundaries.
- Updated access constants and the Company Access UI copy for the new
active-checkout management grant.
- Added focused regression tests covering cross-company denial, agent
self-mutation denial, admin-only operations, and active checkout
ownership.
- Rebased the branch onto `public-gh/master` and fixed validation
fallout from the rebase: heartbeat-context route ordering and a company
import/export e2e fixture that now opts out of direct-hire approval
before using direct agent creation.
- Updated onboarding and signoff e2e setup to create seed agents through
`/agent-hires` plus board approval, so they remain compatible with the
approval-gated new-agent default.
- Addressed Greptile feedback by removing a duplicate company export API
alias, avoiding N+1 reporting-chain lookups in active-checkout override
checks, allowing agent mutations on unassigned `in_progress` issues, and
blocking NAT64 invite-probe targets.

## Verification

- `pnpm exec vitest run
server/src/__tests__/issues-goal-context-routes.test.ts
cli/src/__tests__/company-import-export-e2e.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/adapter-routes-authz.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/company-portability-routes.test.ts
server/src/__tests__/costs-service.test.ts
server/src/__tests__/invite-test-resolution-route.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/agent-adapter-validation-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/invite-test-resolution-route.test.ts`
- `pnpm -r typecheck`
- `pnpm --filter server typecheck`
- `pnpm --filter ui typecheck`
- `pnpm build`
- `pnpm test:e2e -- tests/e2e/onboarding.spec.ts
tests/e2e/signoff-policy.spec.ts`
- `pnpm test:e2e -- tests/e2e/signoff-policy.spec.ts`
- `pnpm test:run` was also run. It failed under default full-suite
parallelism with two order-dependent failures in
`plugin-routes-authz.test.ts` and `routines-e2e.test.ts`; both files
passed when rerun directly together with `pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/routines-e2e.test.ts`.

## Risks

- Medium risk: this changes authorization behavior across multiple
sensitive API surfaces, so callers that depended on broad board/company
access may now receive `403` or `409` until they use the correct
governance path.
- Direct agent creation now respects the company-level board-approval
requirement; integrations that need pending hires should use
`/api/companies/:companyId/agent-hires`.
- Active in-progress issue mutations now require checkout ownership or
an explicit management override, which may reveal workflow assumptions
in older automation.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent, tool-using workflow with local shell,
Git, GitHub CLI, and repository tests.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-20 10:56:48 -05:00 committed by GitHub
parent 549ef11c14
commit 7a329fb8bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1903 additions and 138 deletions

View file

@ -4,7 +4,12 @@ import {
randomBytes,
timingSafeEqual
} from "node:crypto";
import { lookup as dnsLookup } from "node:dns/promises";
import fs from "node:fs";
import type { IncomingMessage, RequestOptions as HttpRequestOptions } from "node:http";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Router } from "express";
@ -84,6 +89,7 @@ const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
const INVITE_TOKEN_MAX_RETRIES = 5;
const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000;
const INVITE_RESOLUTION_DNS_TIMEOUT_MS = 3_000;
type MemberGrantPayload = {
permissionKey: PermissionKey;
@ -2101,44 +2107,259 @@ type InviteResolutionProbe = {
message: string;
};
type InviteResolutionLookupResult = {
address: string;
family?: number;
};
type ResolvedInviteResolutionTarget = {
url: URL;
resolvedAddress: string;
resolvedAddresses: string[];
hostHeader: string;
tlsServername?: string;
};
type InviteResolutionHeadResponse = {
httpStatus: number | null;
};
type InviteResolutionNetwork = {
lookup(hostname: string): Promise<InviteResolutionLookupResult[]>;
requestHead(
target: ResolvedInviteResolutionTarget,
timeoutMs: number
): Promise<InviteResolutionHeadResponse>;
};
function parseIpv4Address(address: string) {
const parts = address.split(".");
if (parts.length !== 4) return null;
const parsed = parts.map((part) => {
if (!/^\d+$/.test(part)) return NaN;
return Number(part);
});
if (parsed.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return null;
}
return parsed as [number, number, number, number];
}
function isPrivateOrReservedIpv4(address: string) {
const octets = parseIpv4Address(address);
if (!octets) return true;
const [a, b, c] = octets;
if (a === 0) return true;
if (a === 10) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
if (a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 0 && c === 0) return true;
if (a === 192 && b === 168) return true;
if (a === 192 && b === 0 && c === 2) return true;
if (a === 192 && b === 88 && c === 99) return true;
if (a === 198 && (b === 18 || b === 19)) return true;
if (a === 198 && b === 51 && c === 100) return true;
if (a === 203 && b === 0 && c === 113) return true;
if (a >= 224) return true;
return false;
}
function parseMappedIpv4Hex(address: string) {
const match = address.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (!match) return null;
const hi = Number.parseInt(match[1]!, 16);
const lo = Number.parseInt(match[2]!, 16);
if (!Number.isInteger(hi) || !Number.isInteger(lo)) return null;
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
}
function isPrivateOrReservedIpv6(address: string) {
const lower = address.toLowerCase();
if (lower.startsWith("::ffff:")) {
const mappedIpv4 = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
if (mappedIpv4?.[1]) return isPrivateOrReservedIpv4(mappedIpv4[1]);
const mappedIpv4Hex = parseMappedIpv4Hex(lower);
if (mappedIpv4Hex) return isPrivateOrReservedIpv4(mappedIpv4Hex);
return true;
}
if (lower === "::" || lower === "::1") return true;
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
if (/^fe[89ab]/.test(lower)) return true;
if (lower.startsWith("ff")) return true;
if (lower === "100::" || lower.startsWith("100:")) return true;
if (lower.startsWith("2001:db8:") || lower === "2001:db8::") return true;
if (lower.startsWith("2001:2:") || lower === "2001:2::") return true;
if (lower.startsWith("2002:")) return true;
if (lower.startsWith("64:ff9b:")) return true;
return false;
}
function isPublicIpAddress(address: string) {
const ipVersion = isIP(address);
if (ipVersion === 4) return !isPrivateOrReservedIpv4(address);
if (ipVersion === 6) return !isPrivateOrReservedIpv6(address);
return false;
}
function hostnameForResolution(url: URL) {
return url.hostname.replace(/^\[|\]$/g, "");
}
async function defaultInviteResolutionLookup(
hostname: string
): Promise<InviteResolutionLookupResult[]> {
return dnsLookup(hostname, { all: true, verbatim: true });
}
async function defaultInviteResolutionHeadRequest(
target: ResolvedInviteResolutionTarget,
timeoutMs: number
): Promise<InviteResolutionHeadResponse> {
return new Promise((resolve, reject) => {
const url = target.url;
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
const options: HttpRequestOptions & { servername?: string } = {
protocol: url.protocol,
hostname: target.resolvedAddress,
port: url.port || undefined,
method: "HEAD",
path: `${url.pathname}${url.search}`,
headers: {
Host: target.hostHeader
}
};
if (target.tlsServername) {
options.servername = target.tlsServername;
}
let settled = false;
const req = request(options, (response: IncomingMessage) => {
settled = true;
response.resume();
resolve({ httpStatus: response.statusCode ?? null });
});
req.setTimeout(timeoutMs, () => {
if (settled) return;
const error = new Error("Invite resolution probe timed out");
error.name = "AbortError";
req.destroy(error);
});
req.on("error", (error) => {
if (settled) return;
settled = true;
reject(error);
});
req.end();
});
}
const defaultInviteResolutionNetwork: InviteResolutionNetwork = {
lookup: defaultInviteResolutionLookup,
requestHead: defaultInviteResolutionHeadRequest
};
let inviteResolutionNetwork = defaultInviteResolutionNetwork;
export function setInviteResolutionNetworkForTest(
network: Partial<InviteResolutionNetwork> | null
) {
inviteResolutionNetwork = network
? { ...defaultInviteResolutionNetwork, ...network }
: defaultInviteResolutionNetwork;
}
async function lookupInviteResolutionHostname(hostname: string) {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
inviteResolutionNetwork.lookup(hostname),
new Promise<never>((_, reject) => {
timeout = setTimeout(
() =>
reject(
badRequest(
`url hostname DNS lookup timed out after ${INVITE_RESOLUTION_DNS_TIMEOUT_MS}ms`
)
),
INVITE_RESOLUTION_DNS_TIMEOUT_MS
);
})
]);
} catch (error) {
if (error instanceof Error && "status" in error) throw error;
throw badRequest("url hostname could not be resolved");
} finally {
if (timeout) clearTimeout(timeout);
}
}
async function resolveInviteResolutionTarget(
url: URL
): Promise<ResolvedInviteResolutionTarget> {
const hostname = hostnameForResolution(url);
const results = await lookupInviteResolutionHostname(hostname);
if (results.length === 0) {
throw badRequest("url hostname did not resolve to any addresses");
}
const resolvedAddresses = results.map((result) => result.address);
const unsafeAddress = resolvedAddresses.find((address) => !isPublicIpAddress(address));
if (unsafeAddress) {
throw badRequest(
"url resolves to a private, local, multicast, or reserved address"
);
}
return {
url,
resolvedAddress: resolvedAddresses[0]!,
resolvedAddresses,
hostHeader: url.host,
tlsServername: url.protocol === "https:" && isIP(hostname) === 0
? hostname
: undefined
};
}
async function probeInviteResolutionTarget(
url: URL,
target: ResolvedInviteResolutionTarget,
timeoutMs: number
): Promise<InviteResolutionProbe> {
const startedAt = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "HEAD",
redirect: "manual",
signal: controller.signal
});
const response = await inviteResolutionNetwork.requestHead(target, timeoutMs);
const durationMs = Date.now() - startedAt;
if (
response.ok ||
response.status === 401 ||
response.status === 403 ||
response.status === 404 ||
response.status === 405 ||
response.status === 422 ||
response.status === 500 ||
response.status === 501
response.httpStatus !== null &&
(
(response.httpStatus >= 200 && response.httpStatus < 300) ||
response.httpStatus === 401 ||
response.httpStatus === 403 ||
response.httpStatus === 404 ||
response.httpStatus === 405 ||
response.httpStatus === 422 ||
response.httpStatus === 500 ||
response.httpStatus === 501
)
) {
return {
status: "reachable",
method: "HEAD",
durationMs,
httpStatus: response.status,
message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.`
httpStatus: response.httpStatus,
message: `Webhook endpoint responded to HEAD with HTTP ${response.httpStatus}.`
};
}
return {
status: "unreachable",
method: "HEAD",
durationMs,
httpStatus: response.status,
message: `Webhook endpoint probe returned HTTP ${response.status}.`
httpStatus: response.httpStatus,
message: response.httpStatus === null
? "Webhook endpoint probe did not return an HTTP status."
: `Webhook endpoint probe returned HTTP ${response.httpStatus}.`
};
} catch (error) {
const durationMs = Date.now() - startedAt;
@ -2161,8 +2382,6 @@ async function probeInviteResolutionTarget(
? error.message
: "Webhook endpoint probe failed."
};
} finally {
clearTimeout(timeout);
}
}
@ -2927,7 +3146,8 @@ export function accessRoutes(
const timeoutMs = Number.isFinite(parsedTimeoutMs)
? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs)))
: 5000;
const probe = await probeInviteResolutionTarget(target, timeoutMs);
const resolvedTarget = await resolveInviteResolutionTarget(target);
const probe = await probeInviteResolutionTarget(resolvedTarget, timeoutMs);
res.json({
inviteId: invite.id,
testResolutionPath: `/api/invites/${token}/test-resolution`,