Move the engine's endpoints off the proxied listener and add unshun #12

Merged
james merged 1 commit from admin-listener into main 2026-10-10 13:51:00 +00:00
Owner

Stacked on the CENTINELA_SHUN_ON_VERDICT PR. Until that merges, the diff here includes its commit.

Problem

In proxy mode the main listener faces the internet, and it served the engine's own endpoints under every proxied host:

  • /v1/inspect takes the client address from its caller. Anyone could have any address shunned with one request.
  • /debug/vars published the counters and process details.
  • /authz was reachable too.

Confirmed against production from outside the cluster with a benign request: /v1/inspect answered with a verdict and /debug/vars returned the counters.

Separately, the only way to lift a shun was to restart the pods, which frees every client that was shunned for good reason.

Change

Main listener. With a proxy configured it now serves only /healthz and the proxied apps. Forward-auth deployments, where that listener is the integration surface, are unchanged.

Admin listener. CENTINELA_ADMIN_LISTEN, default 127.0.0.1:9108.

  • Startup fails unless the address is loopback. off disables it.
  • It serves GET /debug/vars, /authz and POST /v1/inspect for trying a request by hand, and POST /unshun?client=<address>.

Unshun. Lifts the shun, clears that client's count of blocked requests, and logs client unshunned with the client and whether it had been shunned.

The image has no shell or HTTP client, so the binary is its own client. Each replica keeps its own shun list, so ask every pod:

for p in $(kubectl -n centinela get pods -o name); do
  kubectl -n centinela exec "$p" -- /centinela unshun 203.0.113.9
done

Access control is whoever may kubectl exec in the namespace.

Behaviour changes to know

  • /debug/vars is no longer on port 9107 in proxy mode. Use kubectl port-forward to 9108.
  • Proxied apps now receive requests for /v1/inspect, /authz and /debug/vars themselves, after the usual checks.
  • A lifted shun does not clear a cached verdict: the same request is refused again until CENTINELA_CACHE_TTL passes.
  • There is no "list shuns" or "unshun all".

Tests

  • Proxy mode: the public listener passes /v1/inspect, /authz, /debug/vars and /unshun to the app, and a caller there cannot get an address shunned; /healthz still answers.
  • Forward-auth mode keeps its endpoints, and does not serve /unshun.
  • Admin: unshun lifts a shun, reports one that was not there, needs a client, and refuses GET.
  • Engine: unshun also resets the repeat-offender count.
  • Config: default, off, loopback forms accepted; wildcard, private, hostname and bare-port forms refused.
  • End to end against the built binary in proxy mode: public endpoints reach the upstream, a client shunned after two hard blocks is served again after centinela unshun.

make vet, make build and make test (race detector) pass.

Merging to main runs build-and-push, which tags and publishes the next patch version.

**Stacked on the `CENTINELA_SHUN_ON_VERDICT` PR.** Until that merges, the diff here includes its commit. ## Problem In proxy mode the main listener faces the internet, and it served the engine's own endpoints under every proxied host: - `/v1/inspect` takes the client address from its caller. Anyone could have any address shunned with one request. - `/debug/vars` published the counters and process details. - `/authz` was reachable too. Confirmed against production from outside the cluster with a benign request: `/v1/inspect` answered with a verdict and `/debug/vars` returned the counters. Separately, the only way to lift a shun was to restart the pods, which frees every client that was shunned for good reason. ## Change **Main listener.** With a proxy configured it now serves only `/healthz` and the proxied apps. Forward-auth deployments, where that listener is the integration surface, are unchanged. **Admin listener.** `CENTINELA_ADMIN_LISTEN`, default `127.0.0.1:9108`. - Startup fails unless the address is loopback. `off` disables it. - It serves `GET /debug/vars`, `/authz` and `POST /v1/inspect` for trying a request by hand, and `POST /unshun?client=<address>`. **Unshun.** Lifts the shun, clears that client's count of blocked requests, and logs `client unshunned` with the client and whether it had been shunned. The image has no shell or HTTP client, so the binary is its own client. Each replica keeps its own shun list, so ask every pod: ``` for p in $(kubectl -n centinela get pods -o name); do kubectl -n centinela exec "$p" -- /centinela unshun 203.0.113.9 done ``` Access control is whoever may `kubectl exec` in the namespace. ## Behaviour changes to know - `/debug/vars` is no longer on port 9107 in proxy mode. Use `kubectl port-forward` to 9108. - Proxied apps now receive requests for `/v1/inspect`, `/authz` and `/debug/vars` themselves, after the usual checks. - A lifted shun does not clear a cached verdict: the same request is refused again until `CENTINELA_CACHE_TTL` passes. - There is no "list shuns" or "unshun all". ## Tests - Proxy mode: the public listener passes `/v1/inspect`, `/authz`, `/debug/vars` and `/unshun` to the app, and a caller there cannot get an address shunned; `/healthz` still answers. - Forward-auth mode keeps its endpoints, and does not serve `/unshun`. - Admin: unshun lifts a shun, reports one that was not there, needs a client, and refuses GET. - Engine: unshun also resets the repeat-offender count. - Config: default, `off`, loopback forms accepted; wildcard, private, hostname and bare-port forms refused. - End to end against the built binary in proxy mode: public endpoints reach the upstream, a client shunned after two hard blocks is served again after `centinela unshun`. `make vet`, `make build` and `make test` (race detector) pass. Merging to `main` runs `build-and-push`, which tags and publishes the next patch version.
Add CENTINELA_SHUN_ON_VERDICT so one verdict need not shun a client
All checks were successful
test / go (pull_request) Successful in 3m41s
security-scan / security-scan (pull_request) Successful in 6m19s
96140a5cbd
A single model verdict could shun a client for CENTINELA_SHUN_TTL. An
authenticated user posting a triage note to a vulnerability tracker's
API was shunned twice this way: the note is prose about a Dockerfile's
ENV block, CRS scored it 18 on its unix command rules, and the model
answered command_injection=0.94. Every later request from that address,
reads included, was refused for 15 minutes.

With CENTINELA_SHUN_ON_VERDICT=false a shun verdict is applied as a
block: the request is refused and counts toward CENTINELA_SHUN_AFTER,
which is then the only thing that shuns. The default, true, keeps the
current behaviour.

This limits what a false positive costs. It does not stop the false
positive: the note is still refused.
Move the engine's endpoints off the proxied listener and add unshun
All checks were successful
test / go (pull_request) Successful in 3m5s
security-scan / security-scan (pull_request) Successful in 20s
f4c1f565b7
In proxy mode the main listener faces the internet, and it served
/v1/inspect, /authz and /debug/vars under every proxied host.
/v1/inspect takes the client address from its caller, so anyone could
have any address shunned with one request, and the counters and process
details were public.

The main listener now serves only /healthz and the proxied apps when a
proxy is configured. Forward-auth deployments, where that listener is
the integration surface, are unchanged.

CENTINELA_ADMIN_LISTEN (default 127.0.0.1:9108) is a second listener
for the operator. It refuses to start on anything but a loopback
address. It serves the counters, the engine's endpoints for trying a
request by hand, and POST /unshun?client=<address>, which lifts a shun,
clears that client's count of blocked requests and logs it.

The image has no shell or HTTP client, so `centinela unshun <address>`
is the client: run it with kubectl exec in each pod, since every
replica keeps its own shun list.

🔎 ojo scan results

Severity Count
🟠 HIGH 2
🟡 MEDIUM 4
🟢 LOW 1
⚪ UNKNOWN 5
Details (12)
Type Severity ID/Rule Location Description
vuln 🟠 HIGH CVE-2026-107826 github.com/corazawaf/coraza/[email protected] Coraza JSON body processor: argument-limit truncation reopens an unbounded-depth gjson.Valid stack overflow (process crash)
vuln 🟠 HIGH CVE-2026-41510 github.com/corazawaf/coraza/[email protected] Coraza: Silent argument drop at ArgumentLimit allows bypass of ARGS-targeted rules via parameter flooding
vuln 🟡 MEDIUM GHSA-3wr7-993q-jrff github.com/corazawaf/coraza/[email protected] Coraza: Multipart filename* (RFC 5987) charset restriction lets a decoy filename bypass FILES-based rules
vuln 🟡 MEDIUM GHSA-5gj4-9gm7-2fx2 github.com/corazawaf/coraza/[email protected] Coraza body processor has a JSON key collision that allows unauthenticated attackers to bypass OWASP CRS inspection
vuln 🟡 MEDIUM GHSA-g4qm-m288-5cp9 github.com/corazawaf/coraza/[email protected] Coraza has Cookie Parser Confusion
vuln 🟡 MEDIUM GHSA-w253-m66g-rx24 github.com/corazawaf/coraza/[email protected] Coraza: URL-encoded form Content-Type parameters bypass Coraza body inspection
misconfig 🟢 LOW dockerfile-no-healthcheck Dockerfile:1 image has no HEALTHCHECK
vuln ⚪ UNKNOWN CVE-2026-78659 golang.org/x/[email protected] HTTP/2 server memory exhaustion due to Trailer headers in net/http
vuln ⚪ UNKNOWN CVE-2026-78660 golang.org/x/[email protected] HTTP/2 transport accepts malformed framing-related headers in net/http
vuln ⚪ UNKNOWN CVE-2026-78669 golang.org/x/[email protected] Excessive CPU consumption from repeated initial window changes in net/http
vuln ⚪ UNKNOWN CVE-2026-78663 golang.org/x/[email protected] Double flow control refund on HTTP/2 server streams in net/http
vuln ⚪ UNKNOWN CVE-2026-97032 golang.org/x/[email protected] HTTP/2 server crash due to HPACK encoder race in net/http
<!-- ojo-scan-summary --> ### 🔎 ojo scan results | Severity | Count | |---|---| | 🟠 HIGH | 2 | | 🟡 MEDIUM | 4 | | 🟢 LOW | 1 | | ⚪ UNKNOWN | 5 | <details><summary>Details (12)</summary> | Type | Severity | ID/Rule | Location | Description | |---|---|---|---|---| | vuln | 🟠 HIGH | <a href="https://github.com/corazawaf/coraza/security/advisories/GHSA-6gcq-wc29-5xf2" target="_blank" rel="noopener noreferrer">CVE-2026-107826</a> | github.com/corazawaf/coraza/[email protected] | Coraza JSON body processor: argument-limit truncation reopens an unbounded-depth gjson.Valid stack overflow (process crash) | | vuln | 🟠 HIGH | <a href="https://github.com/corazawaf/coraza/security/advisories/GHSA-6r3q-mjv7-xr8m" target="_blank" rel="noopener noreferrer">CVE-2026-41510</a> | github.com/corazawaf/coraza/[email protected] | Coraza: Silent argument drop at ArgumentLimit allows bypass of ARGS-targeted rules via parameter flooding | | vuln | 🟡 MEDIUM | <a href="https://github.com/corazawaf/coraza/security/advisories/GHSA-3wr7-993q-jrff" target="_blank" rel="noopener noreferrer">GHSA-3wr7-993q-jrff</a> | github.com/corazawaf/coraza/[email protected] | Coraza: Multipart filename* (RFC 5987) charset restriction lets a decoy filename bypass FILES-based rules | | vuln | 🟡 MEDIUM | <a href="https://github.com/corazawaf/coraza/security/advisories/GHSA-5gj4-9gm7-2fx2" target="_blank" rel="noopener noreferrer">GHSA-5gj4-9gm7-2fx2</a> | github.com/corazawaf/coraza/[email protected] | Coraza body processor has a JSON key collision that allows unauthenticated attackers to bypass OWASP CRS inspection | | vuln | 🟡 MEDIUM | <a href="https://github.com/corazawaf/coraza/security/advisories/GHSA-g4qm-m288-5cp9" target="_blank" rel="noopener noreferrer">GHSA-g4qm-m288-5cp9</a> | github.com/corazawaf/coraza/[email protected] | Coraza has Cookie Parser Confusion | | vuln | 🟡 MEDIUM | <a href="https://github.com/corazawaf/coraza/security/advisories/GHSA-w253-m66g-rx24" target="_blank" rel="noopener noreferrer">GHSA-w253-m66g-rx24</a> | github.com/corazawaf/coraza/[email protected] | Coraza: URL-encoded form Content-Type parameters bypass Coraza body inspection | | misconfig | 🟢 LOW | dockerfile-no-healthcheck | <a href="https://git.colibrisec.org/ColibriSec/centinela/src/commit/f4c1f565b738885fbd4f49791d67f7e544fb7522/Dockerfile#L1" target="_blank" rel="noopener noreferrer">Dockerfile:1</a> | image has no HEALTHCHECK | | vuln | ⚪ UNKNOWN | <a href="https://go.dev/cl/847185" target="_blank" rel="noopener noreferrer">CVE-2026-78659</a> | golang.org/x/[email protected] | HTTP/2 server memory exhaustion due to Trailer headers in net/http | | vuln | ⚪ UNKNOWN | <a href="https://go.dev/cl/835145" target="_blank" rel="noopener noreferrer">CVE-2026-78660</a> | golang.org/x/[email protected] | HTTP/2 transport accepts malformed framing-related headers in net/http | | vuln | ⚪ UNKNOWN | <a href="https://go.dev/cl/847186" target="_blank" rel="noopener noreferrer">CVE-2026-78669</a> | golang.org/x/[email protected] | Excessive CPU consumption from repeated initial window changes in net/http | | vuln | ⚪ UNKNOWN | <a href="https://go.dev/cl/847187" target="_blank" rel="noopener noreferrer">CVE-2026-78663</a> | golang.org/x/[email protected] | Double flow control refund on HTTP/2 server streams in net/http | | vuln | ⚪ UNKNOWN | <a href="https://go.dev/cl/847188" target="_blank" rel="noopener noreferrer">CVE-2026-97032</a> | golang.org/x/[email protected] | HTTP/2 server crash due to HPACK encoder race in net/http | </details>
james merged commit fd61563379 into main 2026-10-10 13:51:00 +00:00
james deleted branch admin-listener 2026-10-10 13:51:04 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
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
ColibriSec/centinela!12
No description provided.