Skip to content

Forge / cloud security audit — least-privilege (2026-07-01)

  • Scope: the cloud multi-tenant path that connects iterion to git forges (GitHub first), the OAuth-app store, repo provisioning, and the credentials those flows produce and hand to bots.
  • Method: multi-agent read of pkg/forge, pkg/auth, pkg/server, pkg/secrets, pkg/runner, pkg/sandbox, pkg/identity, pkg/trigger, followed by targeted verification of the highest-risk auth paths.
  • Commit audited: 9b03ce6dd.
  • Bottom line: no clear-cut remotely-exploitable vulnerability was found (no cross-tenant leak, no auth-bypass, no CSRF/open-redirect, no token leakage). The material findings are least-privilege weaknesses — a broad, durable forge token shared across a connection's repos/bots, with no default egress lock — which compound under prompt injection. One privileged SSRF (org/team-admin) and minor hardening round it out.

Verdict table

#AreaVerdictSeverityEvidence
A1OAuth connect CSRF / PKCE / stateOKsingle-use state + PKCE + agent-binding cookie compared with subtle.ConstantTimeCompare; tenant from signed state, not URL/JWT — forge_routes.go:443-492
A2redirect_uriOKforgeOAuthRedirectURI() is a server-derived constant used identically in authorize + exchange; post-connect redirect via safeNext()forge_routes.go:235, :459, :498
A3Webhook signature verificationOKHMAC-SHA256, hmac.Equal (constant-time), length-checked, secret sealed with AAD webhook_hmac_secret:<id>, never stored plaintext — webhooks/token.go:64-84
A4Authorization gatingOKevery mutating forge/oauth-app/webhook/provisioning route gated canManageTeam (admin/owner), reads canViewTeamforge_provisioning_routes.go, forge_oauth_app_routes.go, webhooks_routes.go
A5Tenancy isolationOKall stores keyed on TenantID; defensive re-checks return ErrConnectionNotFound without leaking existence; runner Terms on tenant mismatch — orchestrator.go:114-120, runner/loop.go
A6Secrets at restOKAES-256-GCM + per-record AAD (forge_conn:, generic_secret:, forge_oauth_app:, run_secrets:); SealedPayload/SealedSecret are json:"-"connection_sealer.go, secrets/generic.go
A7Token leakage (logs / API / errors)OKno token value logged; oauth_routes.go:231/256 log owner+kind+expiry only; seal errors don't echo values
A8mode=auto forge admin tokenOKpassed transiently to CreateOAuthApp, never sealed/stored — forge_oauth_app_routes.go:117-151
A9github_app token stalenessOK (correctness, not security)AppRefresher re-mints installation token; RefreshWorker selects ExpiringBefore and rewrites the managed secret 5m pre-expiry — forge_routes.go:275-281, server_lifecycle.go:104, github/app_client.go:210
F1Broad durable forge token shared per-connectionWeaknessHIGHensureManagedSecret copies conn.AdminToken() verbatim into a durable connection-level GenericSecret reused by every repo/bot: OAuth-App = full user repo+read:org; github_app = whole-installation token (not per-repo) — orchestrator.go:407-445
F2forge_token has no default egress lockRisk (compounds F1)HIGH/MEDeffectiveSecretHosts(nil, nil) → nil = allow-any; managed secret sets no AllowedHosts and workflows rarely declare hosts: for forge_token, so the guard materializes the real token toward any host at shell-exec. Sandbox network: open by default. — secretguard.go:123-152
F3Scopes too broad / opaqueWeaknessMEDread:org requested by default though only needed for org-repo listing; requested scopes not surfaced in the OAuth-app UI — github/oauth.go:19
F4SSRF via self-hosted forge base URLVuln (privileged)MEDCanonicalBaseURL only normalizes scheme+host — no loopback/RFC1918/link-local/metadata rejection — yet the server makes requests to it (WhoAmI, token exchange, hook + app provisioning). Requires an authenticated team/org-admin, who in a SaaS is not a platform operator. — types.go:167
F5managed_secret_id exposed in API responsesInfoLOWimplementation-detail id in JSON (useless without the master key) — repo_integration_store.go

Is it safe today?

Against an external / cross-tenant attacker: yes — the connect flow, tenancy, sealing, authorization, and webhook verification hold. There is no path found for one tenant to use another's forge credentials, hijack a connection via CSRF, or read a token from the API/logs.

The residual exposure is blast-radius under compromise: if a bot is prompt-injected (the exact threat the permission-gate work targets), it runs with a forge token that (F1) is broader than the one repo it was provisioned for and (F2) can egress anywhere by default. That combination is what the Phase-2 hardening removes. F4 is a genuine SSRF but is gated behind team/org-admin, so it is a privileged-insider vector, not an anonymous one.

Hardening applied (this pass)

  • H2 / F2 — DONE. The managed forge secret is created with AllowedHosts = [forge host] (orchestrator.go forgeTokenEgressHosts + ensureManagedSecret); the secret's own egress lock now travels through every resolution tier (seeded in buildGenericResolution, intersected — never broadened — with bindings) so the Tier-0 webhook override no longer leaks it as allow-any. Chain: secret → GenericResolution.AllowedHostsRunBundle.GenericSecretHosts → runner GenericHostssecretguard. Parent-domain match means github.com covers api./codeload./uploads. Tests: TestResolveGenericWithBindings_SecretOwnEgressLock, TestProvision_SingleBot.
  • H1 / F1 — PARTIAL. MintInstallationToken now takes InstallationTokenOptions{Repositories, Permissions} and iterion pins every installation-token mint to the least-privilege RuntimeInstallationPermissions set (never the installation's full grant), and the runtime token (AppRefresher) is scoped to the connection's provisioned repo set (forgeConnRepoNames) — no longer the whole installation. Tests: TestMintInstallationToken_NarrowsScope. Deferred (follow-on): per-single- repo scoping of the connect-time creation token requires re-shaping the connection-level managed secret + refresh model; staged to avoid regressing the working token-rotation path. The github_app path was already least-privilege in the dimensions that matter to the "don't propagate a user's broad rights" concern (app identity ≠ user identity; fixed minimal permissions; operator-selected repos).
  • H3 / F3 — DONE. read:org dropped from GitHub OAuth DefaultScopes (github/oauth.go); repo alone still lists org repos via affiliation=organization_member. Follow-on: surface the requested scopes in the OAuth-app UI.
  • H5 / F4,F5 — DONE. All forge outbound calls route through forgeHTTPClient() (a httpdial.SafeTransport client honoring outboundStrict()) — public-unicast validation on every dial (redirect hops included), rebinding-proof, blocking loopback/RFC1918/link-local/metadata in cloud / non-loopback-bind modes (forge_routes.go). managed_secret_id is now json:"-" on Connection, RepoIntegration, and ProvisionResult.

Follow-ons — done (second pass)

  • H1 creation-narrowing — DONE. forge.Orchestrator.narrowGitHubAppSecret (injected GitHubAppMinter = server's forgeAppMinter) re-mints the managed github_app token scoped to the connection's provisioned repo set + minimal permissions after each provision — so the runtime token tracks exactly the repos iterion operates on immediately, instead of the whole installation until the ~55-min refresh. Best-effort (a mint failure keeps the prior minimal- permission token; never blocks a provision) and multi-repo safe (re-scopes to the full current set every time). Test: TestNarrowGitHubAppSecret.
  • H4 — DONE. The studio connect form (ConnectForm.tsx) makes the least-privilege GitHub App path the recommended default for GitHub (auto-selected when a server App is configured, listed first with a "Recommended" badge + a least-privilege note) over the broad OAuth-App repo.
  • H3-UI — DONE. The OAuth-app registration form (RegisterOAuthAppForm.tsx) shows the exact scope set the app will request per provider as chips (DEFAULT_OAUTH_SCOPES in forgeShared.ts, mirroring the Go DefaultScopes), flagging GitHub's broad repo and steering to the GitHub-App path.

Still deferred

  • H1 per-single-repo — scoping each bot's token to only its repo (rather than the connection's repo set) would require per-integration secrets instead of the shared connection-level managed secret (a store + refresh-model refactor). Accepted limitation: the current per-repo-set scope + minimal permissions + egress lock already keeps the token within one team's operated repos; per-single-repo is marginal defense-in-depth at high refactor cost.