- Go 98.9%
- Dockerfile 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| docs | ||
| manifests | ||
| .dockerignore | ||
| .pi-lens.json | ||
| challenge.go | ||
| challenge_test.go | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| HANDOVER.md | ||
| LICENSE | ||
| main.go | ||
| page.go | ||
| README.md | ||
| server.go | ||
| server_test.go | ||
| stats.go | ||
| stats_test.go | ||
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.