fix(forgejo): deny authorized-integration issuer fetches, and audit every service account #348
Inga granskare
Etiketter
Inga etiketter
area/backups
area/ci
area/control-panel
area/identity
area/infra
area/observability
area/payments
area/security
area/storage
area/web
blocked
needs-info
needs-triage
ready-for-implementation
type
bug
type
chore
type
docs
type
epic
type
feature
type
task
wontfix
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Inget förfallodatum satt.
Beroenden
Inga beroenden satta
Referens
bitborg/bitborg-infra!348
Läser in…
Hänvisa till i nytt ärende
Ingen beskrivning angiven.
Ta bort grenen "fix/integrations-issuer-and-token-inventory"
Borttagning av en gren är permanent. Även om den borttagna grenen kan fortsätta existera en kort tid innan den faktiskt tas bort, kan det INTE ångras i de flesta fall. Vill du fortsätta?
Closes #313, closes #314.
#313 — authorized integrations accepted any issuer domain.
The trust boundary is an outbound fetch on demand: with open registration, any signed-in account
could save an integration naming an arbitrary issuer, and the instance would dial it on save and on
every auth attempt. There is no way in 16.0.1 to disable the feature or withhold it from ordinary
users, so the dial is the only chokepoint — and because it is the whole capability, blocking it
closes the hole rather than narrowing it.
Semantics were read from the pinned source rather than assumed, and the default turned out to be the
bug: an empty allow-list is not "deny" — upstream appends a builtin matcher permitting every
public host. The block-list also beats the allow-list, returning a blocked error even when the
allow-list matched, so
*is a genuine master switch.Now configured deny-all with a second fail-closed gate, so relaxing either one alone still denies.
This settles the question the issue was gated on: the local Actions issuer is exempt from both
checks — validation skips the external-issuer path for the internal placeholder and verifies against
the in-memory key with no HTTP fetch. The existing integration path is unaffected.
Verified empirically on the local preview, with the real refusals:
A trap worth knowing, now documented: the
create-authorized-integrationadmin command bypassesthese settings entirely, because the CLI loads only database settings and never the full
configuration. Testing the fix that way would show it "failing" and is the wrong test.
Also fixed in passing: the local preview's variable file had drifted behind the template it feeds, so
the render — the very verification path the issue names — failed on
main. It renders again.#314 — one admin token was invisible to the audit.
The audit's account list was a hand-kept duplicate of the service-account list, and that duplication
is exactly how the token stayed invisible. It is now derived from the source list, so the two
cannot drift again. Rendered check: 6 → 7 accounts, with the missing one included.
Also documented: the correct scope for that account, established empirically against the preview
(200 on the read, 403 on the write) and confirmed against the source, which keys required scope on
HTTP method; the two package tokens on the CI account with both stores named and the rotation
consequence; a mislabelled comment on the reconciler credential; and the runner-controller's scopes.
Operator actions after merge
are in the runbook section this PR adds. A green apply is the proof, since the webhook read fails
on an insufficient scope.
newly-visible account.
another repository and is outside this change.
Verification
ansible-playbook site.yml --syntax-checkclean;ansible-lint0 failures across 191 files, profileproduction; Prettier and markdownlint clean; both templates render and the audit script passesbash -n.No
--checkrun, deliberately: one task in this role setscheck_mode: false, so a dry run wouldreach the live admin API from the control node. Nothing was applied and no production token state was
read.
Authorized Integrations (v16) let ANY signed-in account register a trust rule naming an OIDC issuer, and Forgejo then fetches that issuer's discovery document and JWKS — on save and on every authentication attempt. The routes are under /user/settings, registration is open (ADR 0029) and app.ini configured none of [authorized_integration], so "make an account" was enough to have this server emit outbound HTTPS requests to a host of the visitor's choosing, attributable to our IP. 16.0.1 offers no switch that disables the feature or withholds it from ordinary users, so the outbound fetch is the only place to stop it — and it is the whole of the capability, so stopping it closes the finding rather than narrowing it. We hold no integration today and the only issuer in the evaluation's GO column is the LOCAL Actions issuer, which is exempt from both lists (verified in-memory, no HTTP fetch), so the posture is deny-all rather than a narrower allow-list: BLOCKED_DOMAINS = * master switch; upstream's dialer applies the block list even when the allow list matched, and `*` matches every host and IP ALLOWED_DOMAINS our own domain, as a second fail-closed gate — an EMPTY value is not "deny", upstream appends the builtin `external` matcher to it, which is exactly what this bug was Relaxing either one alone still leaves the other denying, so a future PoC has to open this deliberately. ALLOW_LOCALNETWORKS stays false and REQUEST_TIMEOUT/CACHE_TTL are pinned rather than inherited. Verified on the local preview (16-rootless, same code as 16.0.1 for services/auth/authorized_integration.go, modules/hostmatcher/* and modules/setting/authorized_integration.go): a generic integration on an outside issuer is refused at the dial, one on an allow-listed host is refused by the block list, and a forgejo-actions-local integration still saves. The preview render was broken before this (app.ini.j2 reads variables vars.local.yml never defined), so local/vars.local.yml gains the missing ones — that render is the verification path the issue asked for. Closes #313916c37d9e57e213ba775