I run my own Gitea at home. All my repos live there, and the Actions runners that build and deploy everything live in the same Kubernetes cluster. On September 30 I was wiring up a Telegram bot, parakletos-bot, so it could open GitOps pull requests when I am away from the computer. That meant giving a bot account access to Gitea, so I sat down to review what a bot account can actually do.

I thought I had it locked down. Restricted user. Private. Not an admin. Repo creation limit set to 0, so it cannot create anything. Write access only on the one repo it needs.

Then I asked a simple question. What happens if the bot forks a repo?

What “limit 0” meant to me

In my head, a fork is a new repo under the bot’s account. The bot has a limit of 0 repos. So the fork should fail. Done.

That is not how Gitea works by default. There is a setting under [repository] called ALLOW_FORK_WITHOUT_MAXIMUM_LIMIT, and it ships as true. The name says it all once you read it slowly: forks are allowed even past the user’s maximum. The repo creation limit applies to creating repos, and a fork does not count.

So my restricted bot with limit 0 could fork any repo it could read. I tested it. It worked.

Why a fork is a problem

A fork on its own is harmless. The problem is what rides along with it.

Gitea Actions workflows live in .gitea/workflows/ inside the repo. A fork carries that directory, and the fork’s owner can edit it. My Actions runners were registered globally, which means any repo on the instance could schedule jobs on them. And those runners are not toys. They run with a shared Docker-in-Docker daemon on the host network, because that is what it takes to build container images on Kubernetes.

Put those together. A bot account with a limit of 0 forks a repo, edits a workflow in the fork, pushes, and now it has arbitrary code running on a privileged runner inside my cluster. The runner labels did nothing to stop it. Labels pick where a job runs. They are not access control.

I had been treating “can it push to my repo” as the security boundary. The real boundary was “can it get a workflow onto my runner”, and the fork path went straight around the first one.

The fix

The fork part took one line in the Gitea Helm values:

gitea:
  config:
    repository:
      ALLOW_FORK_WITHOUT_MAXIMUM_LIMIT: false

With that, the bot’s fork attempt comes back as a 409. Users with no limit, like my own account, are not affected.

The runner part took a bit more. I deleted the global runners and registered one scoped to my user. Now only my repos can use it, and a repo that is not mine has no runner to land on even if a workflow somehow got there.

And the bot ended up with no Gitea credentials at all. A small sidecar holds the token and only commits through the file API, to an allow-listed set of paths. The bot asks the sidecar; the sidecar decides. But that is a different post.

What I keep from this

Read the default, not the name. “Repo creation limit” sounded like it covered forks. It did not, and the setting that controls forks was sitting one section below with true as the default.

And in any CI system where workflows live in the repo, Write access on a repo is code execution on the runner, with that repo’s secrets. If a bot can get a workflow onto a runner by any path, including a fork, the bot has the runner. Scope the runner. Do not trust the labels.

The whole thing took an afternoon to find and about ten minutes to fix. The expensive part was assuming the limit meant what I wanted it to mean.