Two files in the Gitea source tree, docker/root/etc/templates/app.ini and docker/rootless/etc/templates/app.ini, shipped this line:
REVERSE_PROXY_TRUSTED_PROXIES = *The project's own example config, the one every operator reads, says something else entirely: 127.0.0.0/8,::1/128. Loopback only. The binary distribution inherits that safe value. The container image — the artifact almost everyone actually deploys — overrode it with a wildcard that means every source IP on the network is a trusted reverse proxy. CVE-2026-20896, CVSS 9.8, reported June 21, 2026, fixed in 1.26.3 and re-fixed in 1.26.4 after 1.26.3 introduced a context-deadline regression.
Here is the honest precondition, stated up front rather than three sections down: the wildcard alone did nothing. It became an auth bypass only when an administrator also set ENABLE_REVERSE_PROXY_AUTHENTICATION = true. At that point any client that could reach the container's HTTP port could send curl -H 'X-WEBAUTH-USER: admin' https://git.example.com/ and be that user — no password, no token, no session. With ENABLE_REVERSE_PROXY_AUTO_REGISTRATION on as well, they could conjure accounts that never existed.
That precondition is not a mitigation. It is the whole point. ENABLE_REVERSE_PROXY_AUTHENTICATION = true is exactly what you set when you put your platform's SSO in front of a tenant's git server — it is the intended, documented, supported way to do the thing you were always going to do. The dormant landmine was armed by the platform's own correct configuration. Nobody misconfigured anything. The vendor shipped the hole; the operator shipped the trigger; the two met in production.
So the useful question is not "did you patch Gitea." It is: what else is sitting in the images and charts your platform ships, waiting for a feature you are about to enable? Below are five questions with runnable answers.
Step 0: Get the List
You cannot audit a bill of materials you have not enumerated. For a Helm-deployed platform, render every chart with production values and pull out every image reference, subcharts included:
helm template platform ./charts/platform -f values.production.yaml --include-crds \
| yq -N '.. | select(tag == "!!map" and has("image")) | .image' \
| grep -v '^null$' | sort -uFor a Cluster API fleet, do the same for the management-cluster components — CAPI providers, CNI, ingress, cert-manager, registry, observability — plus every image referenced by the tenant-facing chart. A modest platform lands somewhere between 20 and 60 distinct images. That list is the audit surface.
Q1: Is the Baked Config the Documented Config — and Is It the Config the Process Reads?
Two checks, and the first alone is not enough.
(a) What the image layer ships. For an image with a shell:
docker run --rm --entrypoint cat gitea/gitea:1.26.2 /etc/templates/app.ini | grep -i proxyMost of what a Kubernetes platform ships is distroless or scratch, where --entrypoint cat does not exist. Use a registry client instead — no shell, no runtime, works on any image:
crane export gitea/gitea:1.26.2 - | tar -xO etc/templates/app.ini | grep -i proxyThen diff against what the project documents:
curl -s https://raw.githubusercontent.com/go-gitea/gitea/v1.26.2/custom/conf/app.example.ini \
| grep -i REVERSE_PROXY_TRUSTED_PROXIESFail condition: the image's value differs from the documented example in the permissive direction. Gitea failed exactly here, and the failure was invisible to anyone who read the docs instead of the image.
(b) What the running process reads. Gitea's app.ini in the image is a template, rendered at container start with environment substitution. Chart values, ConfigMap mounts, GITEA__* env vars, and entrypoint scripts all rewrite it. The layer tells you the starting point, not the effective config:
kubectl exec -n gitea deploy/gitea -- cat /data/gitea/conf/app.ini | grep -i trustedSkipping (b) means Q1 can pass clean on a cluster that is actually vulnerable — a chart default or an env var can reintroduce the wildcard after the image did the right thing. Run both.
Q2: Does Any Shipped Config Trust a Request Header for Identity or Origin?
This is the class marker. A trust-boundary hole in a config file almost always reads as "believe what the caller tells you about who the caller is." Grep your rendered configs for the vocabulary:
grep -rniE 'trusted[_-]?prox|x-forwarded|real[_-]?ip|remote[_-]?ip|webauth|forwardedheaders|proxy[_-]?protocol|xff' \
./rendered-configs/Fail condition: any hit whose value is *, 0.0.0.0/0, ::/0, true, or an unbounded hop count. Header-derived identity is only as trustworthy as the list of peers allowed to set it; an unbounded list makes the header a public API for impersonation. This is the same shape as rate limits keyed on X-Forwarded-For, audit logs attributing actions to a spoofable client IP, and IP allowlists evaluated after an untrusted hop.
Q3: Is the Hole Dormant, Waiting on a Flag You Plan to Set?
The most dangerous finding is the one that scans clean today. Cross-reference every permissive trust value from Q2 against its arming flag:
grep -rniE 'enable_reverse_proxy_auth|auth\.proxy|proxy_auth|header[_-]?auth|trust_proxy_auth' \
charts/ rendered-configs/Fail condition: a permissive trust value exists and the arming flag is either set, or on your roadmap. "We do not enable header auth yet" is a schedule, not a control — and the engineer who wires up SSO next quarter will not re-read the image's baked defaults before flipping it. Fix the trust list now, while it costs one line, not later when it costs an incident.
Q4: Can Anything but Your Proxy Reach the Container's Port?
Gitea's threat model assumed the only thing that could talk to port 3000 was the authenticating proxy. On a default Kubernetes install, every pod in the cluster can talk to port 3000. A flat pod network turns "trust the proxy" into "trust every workload any tenant ever deployed."
Check that the workload is actually selected by an ingress policy:
kubectl get netpol -n gitea -o json \
| jq -e '[.items[] | select(.spec.policyTypes[]? == "Ingress")] | length > 0' \
|| echo "FAIL: no ingress NetworkPolicy selects this namespace"Then prove it, which is more convincing than reading YAML:
kubectl run probe --rm -i --restart=Never --image=curlimages/curl -- \
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'X-WEBAUTH-USER: admin' http://gitea-http.gitea.svc:3000/api/v1/userFail condition: anything other than a connection timeout. A 200 means an unprivileged pod in an unrelated namespace just authenticated as your administrator.
Q5: Does Your Own Chart Re-Introduce It Downstream?
Upstream can ship a safe default and your chart can undo it. Do not grep values.yaml for * — glob patterns, image tags, and comments will bury you in false positives, and a check with a 90% false-positive rate gets || true'd within a month. Anchor on key path and value together:
yq -o=json '.' values.production.yaml | jq -r '
paths(scalars) as $p
| [($p | join(".")), (getpath($p) | tostring)]
| @tsv' \
| grep -iE '^[^\t]*(trust|proxy|insecure|anonymous|allowall|skipverify|auth)[^\t]*\t' \
| grep -iE '\t(\*|0\.0\.0\.0/0|::/0|true|trust|none|disabled)$'Fail condition: any row. Each one is a deliberate loosening that someone should be able to justify in a comment next to it. Print the surviving rows in CI so the list stays short and reviewed.
The Same Shape, in Images You Already Run
Gitea is not an outlier. It is a well-documented instance of a pattern that runs through the self-hosted stack: a default chosen to make the five-minute demo work, deployed unchanged into a topology with hundreds of untrusted neighbours.
| Image | The shipped default | Why it was convenient | The boundary it dissolves |
|---|---|---|---|
gitea/gitea ≤ 1.26.2 | REVERSE_PROXY_TRUSTED_PROXIES = * | Works behind any proxy topology with zero config | Who is allowed to assert a user's identity |
redis (official) | Protected mode off, no password | "Easy access between containers via Docker networking" | Who may issue commands to your datastore |
minio/minio | Root credentials minioadmin:minioadmin on first start | Server comes up without a secrets step | Who owns every object in every bucket |
postgres (official) | POSTGRES_HOST_AUTH_METHOD=trust escape hatch | Waives the mandatory POSTGRES_PASSWORD | Whether a password is checked at all |
traefik | forwardedHeaders.insecure: true knob | One line makes X-Forwarded-* work everywhere | Whether the client IP in your logs and ACLs is real |
Three of those five are documented with explicit warnings. Redis's image docs say plainly that if you publish the port "it will be open without a password to anyone." Postgres's say trust is "not recommended since it allows anyone to connect without a password, even if one is set." Traefik's say insecure is "only for tests purposes, not in production." The warnings did not help, because the warning lives in the docs and the value lives in the image — and a docker-compose.yml copied from a blog post carries the value, not the warning.
Gitea's case is worse than the others in one specific way, and it is the way that matters most for a platform: the image contradicted the project's own documentation. An operator who did the responsible thing — read the config reference, confirm the default is loopback-only, deploy — ended up with a wildcard. Diligence produced the wrong answer. That is the failure mode Q1(a) exists to catch, and it is undetectable by any amount of reading.
Fixing It So It Stays Fixed
Finding a hit is the easy half. Four ways to close one, and they do not age equally:
| Remediation | Survives an upstream image bump? | Cost |
|---|---|---|
Set the value explicitly in chart values / env (GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES) | Yes — your value wins on every tag | One line; the default choice |
| Mount a corrected ConfigMap over the config path | Partly — drifts when upstream adds keys you now shadow | Medium; needs re-review each minor |
Rebuild FROM upstream with a patched config layer | Only if you rebuild on every upstream release | You now own a patch cadence |
| Pin the old tag and stop upgrading | No — you stop receiving security fixes | Highest; last resort |
Take the first row unless something forces you off it. Explicit beats inherited: a value you set is a value that shows up in your diffs, your reviews, and your git history.
Then make the default itself a tracked artifact. The structural reason Gitea's wildcard survived across releases is that a default change ships in no changelog diff — nobody reviews a line they never see. So extract it and pin it:
#!/usr/bin/env bash
# ci/audit-image-defaults.sh — fails the build when a shipped default changes.
set -euo pipefail
BASELINE=ci/image-config-baseline.tsv
CURRENT=$(mktemp)
# manifest.tsv: one "image<TAB>path/inside/image" row per config file you care about
while IFS=$'\t' read -r image cfgpath; do
crane export "$image" - 2>/dev/null | tar -xO "${cfgpath#/}" 2>/dev/null \
| grep -inE 'trust|prox|password|auth|insecure|anonymous' \
| sed "s|^|${image}\t${cfgpath}\t|" >> "$CURRENT" || true
done < ci/image-config-manifest.tsv
sort -o "$CURRENT" "$CURRENT"
if ! diff -u "$BASELINE" "$CURRENT"; then
echo "A shipped config default changed. Review the diff above, then update $BASELINE." >&2
exit 1
fiWire it into the same job that bumps image tags. The output is deliberately noisy on first run — that noise is the baseline, and reviewing it once is the point. After that, every upstream bump that quietly changes a trust value stops being invisible and starts being a red build with a two-line diff attached.
Pair it with a default-deny NetworkPolicy per tenant namespace. Q4's probe should time out even when Q1 through Q3 are clean, because defense in depth is what turns "critical auth bypass" into "misconfiguration nobody could reach."
What a Platform Owes a Tenant Who Never Opens a Config File
The tenants of a self-hosted PaaS do not run docker inspect. They push a git repo. They will never read app.ini, never see the chart values, never learn that a header they have never heard of decides who their app thinks they are. Everything in this post happens on the platform's side of a line the tenant cannot see across.
Which is the argument for making these five questions a shipping gate rather than a quarterly exercise. Q1 through Q3 are grep-able and belong in CI. Q4 is a NetworkPolicy and a probe you can run on every cluster you provision. Q5 is a lint on your own repo. None of it takes a security team — it takes a manifest of the images you ship and a build that fails when their defaults move.
Gitea's maintainers did the right thing when it was reported: patched in a day, made reverse-proxy auth opt-in, shipped the follow-up when the fix regressed. The lesson is not that Gitea was careless. It is that a default is a decision made by someone who has never seen your network, and every image you ship contains dozens of them.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.



