No description
  • Go 98.9%
  • Dockerfile 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tobias Brunner 9e1edf9ffd
All checks were successful
Image / test (push) Successful in 27s
Image / build-and-push (push) Successful in 3s
challenge: answer 403 for patch and diff downloads
A 200 page makes a script save HTML as the .patch content and notice
nothing. Answer 403 for .patch and .diff so the script fails loudly, and
keep 200 for the HTML page routes a browser navigates to.

🤖 Generated with Pi (https://github.com/earendil-works/pi) using deepseek-v4.1-flash
2026-10-27 07:30:29 +01:00
.forgejo/workflows ci: valid image tag from the ref, and a Forgejo workflow schema 2026-10-26 13:19:11 +01:00
docs ci: valid image tag from the ref, and a Forgejo workflow schema 2026-10-26 13:19:11 +01:00
manifests challenge: answer 403 for patch and diff downloads 2026-10-27 07:30:29 +01:00
.dockerignore ci: build and push the image via an authorized integration 2026-10-26 13:13:31 +01:00
.pi-lens.json ci: valid image tag from the ref, and a Forgejo workflow schema 2026-10-26 13:19:11 +01:00
challenge.go log: heartbeat with per-window decision counters 2026-10-26 16:28:55 +01:00
challenge_test.go log: heartbeat with per-window decision counters 2026-10-26 16:28:55 +01:00
Dockerfile ci: build and push the image via an authorized integration 2026-10-26 13:13:31 +01:00
go.mod ci: build and push the image via an authorized integration 2026-10-26 13:13:31 +01:00
go.sum ci: build and push the image via an authorized integration 2026-10-26 13:13:31 +01:00
HANDOVER.md docs: prerequisites and repository path layout 2026-10-26 13:04:04 +01:00
LICENSE paths: challenge only the expensive routes, add AGPL-3.0 2026-10-26 14:33:32 +01:00
main.go log: heartbeat with per-window decision counters 2026-10-26 16:28:55 +01:00
page.go ci: build and push the image via an authorized integration 2026-10-26 13:13:31 +01:00
README.md challenge: answer 403 for patch and diff downloads 2026-10-27 07:30:29 +01:00
server.go challenge: answer 403 for patch and diff downloads 2026-10-27 07:30:29 +01:00
server_test.go challenge: answer 403 for patch and diff downloads 2026-10-27 07:30:29 +01:00
stats.go log: heartbeat with per-window decision counters 2026-10-26 16:28:55 +01:00
stats_test.go log: heartbeat with per-window decision counters 2026-10-26 16:28:55 +01:00

forgejo-js-challenge

A small gRPC ext_proc server for kgateway. It answers expensive Forgejo pages with a JavaScript cookie challenge. Clients without JavaScript get a tiny page that sets a cookie and reloads. Crawlers and scrapers do not reload, so they never reach the expensive pages.

It protects one Codey instance at a time. It runs inside the AppCat instance namespace and never changes an AppCat object.

  • Upstream design: docs/2026-10-26-forgejo-js-challenge-extproc.md
  • Work handover: HANDOVER.md

What it does

Request Answer
GET on an expensive path, no cookie 200 with the challenge page and Set-Cookie
GET on a .patch or .diff, no cookie 403 with the same page, so a script fails loudly instead of saving HTML
Same path, with the cookie passes to Forgejo
GET /api/..., /metrics, /info/refs, /login, /assets/... passes to Forgejo
GET /org/repo/raw/branch/... and /org/repo/src/branch/... passes to Forgejo
Any POST (git push, LFS, API) passes to Forgejo
Any other host passes to Forgejo

Challenged by default, for git.opencode.swiss:

Path What it is
/*/*/src/commit/ the file view at a commit
/*/*/src/tag/ the file view at a tag
/raw/commit/ the raw file at a commit
/raw/tag/ the raw file at a tag (SingleDownload)
/blame/ blame
/commit/ one commit, including .patch and .diff
/commits/ the commit list of a branch
/compare/ a comparison, including .diff

failOpen: true on the GatewayExtension means a dead server never blocks the site. The challenge is a filter for clients that cannot run JavaScript. It is not a security control: the cookie is unsigned and a headless browser passes it.

Caveat: a script that fetches .../commit/<sha>.patch or .../compare/....diff without the cookie gets 403 and the challenge page, never the diff. Send the cookie or use the git protocol.

Configuration

All settings are environment variables on the Deployment.

Variable Default Meaning
MODE observe observe logs would-challenge and changes nothing. challenge answers with the page.
HOSTS git.opencode.swiss Comma separated list of hostnames to protect.
CHALLENGE_PATHS /src/commit/,/src/tag/,/raw/commit/,/raw/tag/,/blame/,/commit/,/commits/,/compare/ Path segments that get the challenge. A segment sequence matches anywhere in the path, so /blame/ matches /org/repo/blame/main/x.
SKIP_PATHS see main.go Path segments that always pass, checked before CHALLENGE_PATHS.
COOKIE_NAME codey_js_challenge Name of the cookie.
LOG_LEVEL info info or debug log one line per request decision. warn or error log only the startup line, so a busy instance stays quiet. An unknown value keeps logging.
HEARTBEAT 60s Interval of the aggregate line. 0 disables it. Empty or invalid means 60s.
PORT 9000 gRPC port. Plain h2c, no TLS.

Heartbeat

Every HEARTBEAT window the server writes one line with the decisions of that window. It is independent of LOG_LEVEL, so warn gives you a quiet log that still proves the pod is alive and shows what it blocks:

heartbeat window=1m0s requests=812 challenge=511 cookie=40 skip_path=12 path=210 method=9

Keys are sorted, so the line is stable. Counters are cumulative inside the process and the line reports the delta, which is also the shape a future /metrics endpoint renders as counters. The endpoint is not implemented.

Build and push

CI builds on every push to main and on every tag, and publishes only a v* tag. To publish an image:

git tag v0.1.1
git push origin v0.1.1

The workflow .forgejo/workflows/image.yaml logs in to the registry with an Authorized Integration. It mints an OIDC token with the audience stored in the repository variable REGISTRY_AUD, then uses that token as the registry password. The audience is public and is no secret.

Manual build, for a test without CI:

podman build -t servala.app.codey.ch/servala/forgejo-js-challenge:v0.1.0 .
podman push servala.app.codey.ch/servala/forgejo-js-challenge:v0.1.0

Licence

AGPL-3.0-or-later. See LICENSE.