On a host the operator names, an origin is refused when its account administers the instance or can push to a protected repository #71

Open
tdobbertin-espo wants to merge 2 commits from tdobbertin-espo/forge:feat/origin-guard into main
First-time contributor

Stacked on #70. This branch has that pull's commit as its first commit. Only the top commit is new. Land #70 first; the guard uses its scope words.

A forge keeps an origin's credential on the machine where its agents run. So the risk is the account that a person authorized. A token of an instance administrator, or a token that can push to a protected repository (a repository whose main deploys something), gives that power to every agent run. A team that runs many forges against one Forgejo needs to say "not those accounts" once, for every forge.

The operator now names the hosts to guard. A host is guarded when it is in FORGE_ORIGIN_CLIENT_ID, or when a FORGE_ORIGIN_DENY_PUSH rule covers it. On a guarded host, a credential is refused before it is stored when its account administers the instance (is_admin / site_admin), or when it can push to a protected repository (permissions.push).

  • The guard runs on each way in: a pasted token, the code flow and the device flow. On the code flow the grant is revoked where the provider can do it. A refusal stores nothing.
  • A repository named outright is asked about by name. A wildcard walks /repos/search?uid= sorted by id, with no fallback, at most 20 pages. The walk is complete only when the distinct repositories it saw equal X-Total-Count, and that count did not change between pages. Anything less fails closed (guard_unchecked). A confirmed hit always wins (can_push).
  • A stored origin is checked again: on each OAuth refresh, before the new token is used, and by a sweep on the sync tick (an origin never checked at once, the others daily, one origin per tick). A refusal sets needs_reauth with the guard's sentence. A refresh never clears it.
  • On a guarded host, an origin writes only after its account passed. Until then, or while a check cannot run, write-through, push-through, carried branches, forks and contributions answer origin_paused, and the adapter refuses each request that is not a read. Settings → Origins says so and has Check again (POST /settings/origins/:id/check, once a minute). A check that has not run for a day refuses the origin.
  • The walk fills the repository picker's cache at connect.

What is unchanged: a host that is not guarded behaves as before. With neither variable set, no host is guarded, so a person who runs their own Forgejo can still connect it as its administrator. One change to note: a forge that already sets FORGE_ORIGIN_CLIENT_ID (for example through the chart's forge.originClients) guards those hosts from this version on, so an administrator's account is refused there. If you prefer the guard to come only from FORGE_ORIGIN_DENY_PUSH, that is one line in guarded (src/origins/operator.ts).

docs/ORIGINS.md, "The operator guard", says all of this, and README's origin paragraph names the new variable.

Environment

  • FORGE_ORIGIN_DENY_PUSH (new, optional): comma-separated owner/name patterns. * matches inside one part, a bare owner means owner/*, and host:pattern limits a rule to one host. A rule with no host covers every origin.
  • FORGE_ORIGIN_CLIENT_ID keeps its syntax; its hosts are now guarded too.

Tests

bun 1.3.13, a scrubbed environment (no agent variables):

  • test/origin-guard.test.ts: 23 pass, 0 fail, against the fake origin with a real forge serving git: a pasted token and an OAuth consent, the exact and the wildcard rules, the walk's bound and its count, a refresh and the sweep, paused writes and Check again.
  • With #70's suite and the origin, fork, migrate and cross suites, in one bun test --timeout 60000 call: 489 pass, 0 fail across 35 files.
  • bun scripts/gate.ts and tsc --noEmit: clean.
the 35 files

origin-guard, origin-token-scopes, following, forks, origins, origins-oauth, origins-operator, origins-reconnect, origins-security-auth, origins-security-provider, origins-security-push, origins-sync, origins-sync-settled, web-origins, web-origin-link, mcp-origins, mcp-origin-apps, push-through, trusted-hosts, trusted-hosts-ui, steps-lists, no-polling, refresh-meta, mirror-outbound, mirror-out, carried, migrate, migrate-after, tui-migrate, apps-feedback-mirror, and the five cross files reserved-names, studio, shards, check-list, hollow (each test/<name>.test.ts).

🤖 Generated with Claude Code

**Stacked on #70.** This branch has that pull's commit as its first commit. Only the top commit is new. Land #70 first; the guard uses its scope words. A forge keeps an origin's credential on the machine where its agents run. So the risk is the account that a person authorized. A token of an instance administrator, or a token that can push to a protected repository (a repository whose `main` deploys something), gives that power to every agent run. A team that runs many forges against one Forgejo needs to say "not those accounts" once, for every forge. The operator now names the hosts to guard. A host is guarded when it is in `FORGE_ORIGIN_CLIENT_ID`, or when a `FORGE_ORIGIN_DENY_PUSH` rule covers it. On a guarded host, a credential is refused before it is stored when its account administers the instance (`is_admin` / `site_admin`), or when it can push to a protected repository (`permissions.push`). - The guard runs on each way in: a pasted token, the code flow and the device flow. On the code flow the grant is revoked where the provider can do it. A refusal stores nothing. - A repository named outright is asked about by name. A wildcard walks `/repos/search?uid=` sorted by id, with no fallback, at most 20 pages. The walk is complete only when the distinct repositories it saw equal `X-Total-Count`, and that count did not change between pages. Anything less fails closed (`guard_unchecked`). A confirmed hit always wins (`can_push`). - A stored origin is checked again: on each OAuth refresh, before the new token is used, and by a sweep on the sync tick (an origin never checked at once, the others daily, one origin per tick). A refusal sets `needs_reauth` with the guard's sentence. A refresh never clears it. - On a guarded host, an origin writes only after its account passed. Until then, or while a check cannot run, write-through, push-through, carried branches, forks and contributions answer `origin_paused`, and the adapter refuses each request that is not a read. Settings → Origins says so and has **Check again** (`POST /settings/origins/:id/check`, once a minute). A check that has not run for a day refuses the origin. - The walk fills the repository picker's cache at connect. What is unchanged: a host that is not guarded behaves as before. With neither variable set, no host is guarded, so a person who runs their own Forgejo can still connect it as its administrator. **One change to note:** a forge that already sets `FORGE_ORIGIN_CLIENT_ID` (for example through the chart's `forge.originClients`) guards those hosts from this version on, so an administrator's account is refused there. If you prefer the guard to come only from `FORGE_ORIGIN_DENY_PUSH`, that is one line in `guarded` (`src/origins/operator.ts`). `docs/ORIGINS.md`, "The operator guard", says all of this, and README's origin paragraph names the new variable. ### Environment - `FORGE_ORIGIN_DENY_PUSH` (new, optional): comma-separated `owner/name` patterns. `*` matches inside one part, a bare `owner` means `owner/*`, and `host:pattern` limits a rule to one host. A rule with no host covers every origin. - `FORGE_ORIGIN_CLIENT_ID` keeps its syntax; its hosts are now guarded too. ### Tests bun 1.3.13, a scrubbed environment (no agent variables): - `test/origin-guard.test.ts`: **23 pass, 0 fail**, against the fake origin with a real forge serving git: a pasted token and an OAuth consent, the exact and the wildcard rules, the walk's bound and its count, a refresh and the sweep, paused writes and Check again. - With #70's suite and the origin, fork, migrate and cross suites, in one `bun test --timeout 60000` call: **489 pass, 0 fail across 35 files**. - `bun scripts/gate.ts` and `tsc --noEmit`: clean. <details><summary>the 35 files</summary> origin-guard, origin-token-scopes, following, forks, origins, origins-oauth, origins-operator, origins-reconnect, origins-security-auth, origins-security-provider, origins-security-push, origins-sync, origins-sync-settled, web-origins, web-origin-link, mcp-origins, mcp-origin-apps, push-through, trusted-hosts, trusted-hosts-ui, steps-lists, no-polling, refresh-meta, mirror-outbound, mirror-out, carried, migrate, migrate-after, tui-migrate, apps-feedback-mirror, and the five cross files reserved-names, studio, shards, check-list, hollow (each `test/<name>.test.ts`). </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Connecting a token that lacks a scope names the scope, and the token form lists what a token needs
Some checks are pending
preview / preview (pull_request) Blocked by required conditions
733fd29b90
Forgejo and Gitea answer a token that lacks a required scope with 403
and a body that names the scope: `token does not have at least one of
required scope(s): [read:user]`. The API client mapped every 403 to "the
token may not do that on the origin" and did not read the body. Connect
then said "That token was refused by the origin". That is the same
sentence for a wrong token, an expired token and a narrow token, so a
person could not tell which one they had.

ApiClient now reads the body of a 403 (4 KB at most) and keeps only the
words that look like scopes (`missingScopes`). Nothing else from the
origin goes into a message. The failure carries `missingScopes`, and its
message names them as Forgejo's token page does (`scopeWords`): "the
token is missing User: Read". Sync errors say so too (for example, a
label import without the issue scope).

Connecting with such a token answers `missing_scopes` (422) with "This
token is missing User: Read." and what a token needs. `connectRefusal`
gives the API and the web form one code and one sentence for each
refused connect. An OAuth grant that lacks a scope says so, instead of
"the new token did not work".

The token form lists what a Forgejo or Gitea token needs: User: Read;
Repository and Issue: Read and write; nothing else. When the instance is
known, it links to Forgejo 15's /user/settings/applications/tokens/new
with the name and the scopes already chosen. Gitea and older Forgejo get
the list only.

A token with all its scopes, a wrong token and an expired token behave
as before.

test/origin-token-scopes.test.ts: the body parser (a matching body, an
unrelated body, junk inside the brackets), the words, connect and the
API refusing a token without read:user with nothing stored, and the
form's list and link. test/fake-origin.ts: `userScopeMissing` answers
`/user` as Forgejo does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A forge keeps an origin's credential on the machine where its agents
run. So the risk is the account that a person authorized: a token of an
instance administrator, or a token that can push to a protected
repository, gives that power to every agent run.

The operator now names the hosts to guard. A host is guarded when it is
in FORGE_ORIGIN_CLIENT_ID, or when a FORGE_ORIGIN_DENY_PUSH rule covers
it (`owner/name` patterns, `*` inside one part, a bare `owner` for all
its repositories, `host:pattern` for one host). On a guarded host, a
credential is refused before it is stored when its account administers
the instance (`is_admin` / `site_admin`) or can push to a protected
repository (`permissions.push`).

- The guard runs on each way in: a pasted token, the code flow (the
  grant is revoked where the provider can do it) and the device flow.
  A refusal stores nothing.
- A repository named outright is asked about by name. A wildcard walks
  /repos/search?uid= sorted by id, with no fallback, at most 20 pages.
  The walk is complete only when the distinct repositories it saw equal
  X-Total-Count, and that count did not change between pages. Anything
  less fails closed (`guard_unchecked`). A confirmed hit always wins
  (`can_push`).
- A stored origin is checked again: on each OAuth refresh, before the
  new token is used, and by a sweep on the sync tick (an origin never
  checked at once, the others daily, one origin per tick). A refusal
  sets needs_reauth with the guard's sentence. A refresh never clears
  it.
- On a guarded host, an origin writes only after its account passed.
  Until then, or while a check cannot run, write-through, push-through,
  carried branches, forks and contributions answer `origin_paused`, and
  the adapter refuses each request that is not a read. Settings →
  Origins says so and has Check again (POST /settings/origins/:id/check,
  once a minute). A check that has not run for a day refuses the origin.
- The walk fills the repository picker's cache at connect.

A host that is not guarded behaves as before. With neither variable
set, no host is guarded, so a person who runs their own Forgejo can
still connect it as its administrator.

docs/ORIGINS.md, "The operator guard"; test/origin-guard.test.ts
(against the fake origin, with a real forge serving git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some checks are pending
preview / preview (pull_request) Blocked by required conditions
This pull request has changes conflicting with the target branch.
  • README.md
  • src/actions/branches.ts
  • src/actions/contribute.ts
  • src/actions/origins.ts
  • src/http/api.ts
  • src/http/server.ts
  • src/main.ts
  • src/origins/credential.ts
  • src/origins/operator.ts
  • src/origins/providers/forgejo.ts
  • src/origins/providers/github.ts
  • src/origins/push-through.ts
  • src/origins/write-through.ts
  • src/tui/screens/common.ts
  • src/web/pages/branches.ts
  • src/web/pages/browse.ts
  • src/web/pages/edit.ts
  • src/web/pages/origins.ts
  • src/web/pages/thread.ts
  • test/fake-origin.ts
  • test/origin-guard.test.ts
  • test/origin-token-scopes.test.ts
Some workflows are waiting to be reviewed.
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u feat/origin-guard:tdobbertin-espo-feat/origin-guard
git switch tdobbertin-espo-feat/origin-guard

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff tdobbertin-espo-feat/origin-guard
git switch tdobbertin-espo-feat/origin-guard
git rebase main
git switch main
git merge --ff-only tdobbertin-espo-feat/origin-guard
git switch tdobbertin-espo-feat/origin-guard
git rebase main
git switch main
git merge --no-ff tdobbertin-espo-feat/origin-guard
git switch main
git merge --squash tdobbertin-espo-feat/origin-guard
git switch main
git merge --ff-only tdobbertin-espo-feat/origin-guard
git switch main
git merge tdobbertin-espo-feat/origin-guard
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
trevato-apps/forge!71
No description provided.