English · Português
In front of your app. On your server.
It blocks the attack and holds back what should never leak. You don't touch your code and you don't send your traffic to anyone else's cloud.
"I will give you a name, and I shall call you Sting."
"Sting, the Glinting Dagger", which the Brazilian edition of Magic: The Gathering printed as "Ferroada, o Punhal Reluzente". Art by Nino Is, Tales of Middle-earth (2023). The blade glows when the enemy draws near. That is exactly the job.
Every attack comes in through the same door as legitimate traffic. SQL injection, XSS, path traversal, a scanner running the usual list — it all arrives as an ordinary HTTP request, and most systems only notice once the damage is already in the log. Fixing the backend is the right path, and also the slowest: every framework, every version, every legacy system is a new front.
Ferroada tackles the problem at the infrastructure level: it sits in front of your app, on your server, without changing a single line of it. It blocks known attacks on the way in, masks sensitive data on the way out, and keeps a score per site and client network — anyone acting like a scanner is slowed down before finding anything. DDoS stays with the CDN. The one looking at your API's requests is Ferroada, on your server. Built with Pingora 0.8.1, Cloudflare's proxy engine, compiled into a single binary and shipped as a ~20MB distroless container. This version limits decoded HTTP/2 headers to 64 KiB by default and each HTTP/2 connection to 100 concurrent streams, rejecting oversized headers with 431 before they reach the application. Ferroada pins the exact version in the manifest and in the lockfile so that the local build, the container and CI all use the same base.
Ferroada is not production-grade: it lacks an external security audit and there is no LTS line. Reporting vulnerabilities and what this covers: SECURITY.md.
Internet → [Ferroada :3000] → Your System :8080
↓
Dashboard 127.0.0.1:9000
Multi-site: a single deployment protects several backends at once:
Internet
│
├── meusite.com ──→ backend-a:8080
├── api.meusite.com ──→ backend-b:3000
└── outrosite.com ──→ backend-c:4000
│
[Ferroada :3000]
│
Dashboard 127.0.0.1:9000
| Attack | Where it inspects | Blocked example |
|---|---|---|
| SQL Injection | URI, headers and any HTTP body | ?id=1 UNION SELECT * FROM users |
| XSS (Cross-Site Scripting) | URI, headers and any HTTP body | <script>alert(1)</script>, onerror=, javascript: |
| Path Traversal | URI, headers | ../../etc/passwd, %2e%2e%2f |
| CRLF Injection | URI, headers, body | %0d%0aSet-Cookie: (multipart is skipped in the body) |
| JNDI / Log4Shell | URI, headers, body | ${jndi:ldap://...}, including obfuscated forms |
| Request Smuggling | Headers | Duplicate Content-Length, CL+TE, invalid TE → 400 |
| Sensitive Path Access | URI path + profile | /.env, /.git/, /phpmyadmin, /.aws/credentials |
| Bad Bots | User-Agent | 18 signatures: sqlmap, nikto, nuclei, etc. (weak signal; the abuse quota does not depend on it) |
| Brute Force / basic DDoS | Site + IPv4 or IPv6 /64 prefix | Sliding-window rate limit (configured via env) |
| Stuffing / enumeration / signup | Composite identity (opt-in abuse) |
The same fingerprint across different IPs shares the cap; a site without an abuse block is unchanged |
Application abuse protection is opt-in in the TOML (abuse = { stuffing_max = 10, challenge = "pow" }).
The identity combines site, /64 network, TLS fingerprint (hash of the ClientHello — version,
offered ciphers and compression; it is not the public JA4 string), HTTP fingerprint
(header names, without values), session, API key and jwt.sub. Without an MMDB file
the ASN stays empty and the process still starts. The pow challenge is only for browsers
(Accept: text/html) after the score; an API with JWT or JSON never receives HTML.
There is no ML in the hot path.
SQLi detection covers five categories — classic (UNION SELECT, OR 1=1),
stacked queries (;DROP), blind/time-based (SLEEP, BENCHMARK, WAITFOR),
function abuse (EXTRACTVALUE, LOAD_FILE) and comment evasion
(/*!UNION*/). XSS detection recognizes ~40 event handlers (ontoggle,
onpointerover, onbegin, ...) plus confirm(), prompt(), Function(),
<math>, <base> and url(javascript:).
Files and directories that should never be publicly exposed:
/.env /.git/ /.svn/ /.hg/ /.DS_Store /.htaccess /.htpasswd
/wp-config.php
/phpmyadmin/ /phpinfo.php /server-status /server-info
/actuator/ /console /debug
/config.php /config.yml /config.json /database.yml
/docker-compose.yml /Dockerfile /.dockerenv
/.ssh/ /id_rsa /id_ed25519 /.aws/credentials
/.bash_history /.npmrc /.vscode /.idea /web.config
The generic profile does not assume that /wp-admin, /wp-login.php and xmlrpc.php
are attacks. wordpress monitors those requests without blocking; strict
blocks them. Truly sensitive files, such as wp-config.php, remain
blocked in every profile.
L0 (regex) is always on. L1 is a Coraza sidecar with CRS 4.25 LTS over a
Unix socket, opt-in (WAF_ENGINE=coraza). The Ferroada binary does not link
Coraza: a crash in the ruleset does not take down the proxy. Default timeout 500 ms;
the process only marks the engine as coraza after the ready probe. A missing
sidecar on a require_complete route responds 403 and counts
waf_engine_unavailable — it does not silently turn the WAF off.
CRS paranoia separates blocking from executing: rules at the executing level
run without necessarily blocking. Shadow mode evaluates L1, records the rule ID and
anomaly score in the event, and does not return 403. Exclusions per site, route,
parameter and content-type live in the TOML ([waf.l1], [[sites.l1.exclusions]]).
See deploy/coraza/.
OpenAPI 3.0/3.1 is opt-in per site: a path in the TOML (openapi = "./openapi.yaml"
or openapi = { spec = "./openapi.yaml", unknown_endpoint = "observe" }).
The spec is compiled at startup. Without a spec the proxy behaves exactly as
it does today. With a spec, Ferroada validates method, route, path/query/header, content-type
and the JSON body (types, required, enum, extra fields). An unknown endpoint
is observe or deny (in the pack the default is observe; on a require_complete route
without the key, deny). A method or content-type outside the contract: deny. The event
reports the path and the field; the body never goes into the log.
JWT/JWKS is opt-in per site (jwt = { jwks = "...", issuer = "...", audience = "..." }).
Without the block the site stays the same, including without Authorization. With the block,
the token is the identity key: alg allowlist (default only RS256/ES256;
none rejected; HS* only with explicit algorithms and hmac_secret_env),
iss and aud required, exp/nbf/iat/jti validated, JWKS cached
with a fetch timeout and last-known-good if the IdP goes down. Bindings in the TOML
(jwt.sub == path.account_id, jwt.tenant_id == body.tenant_id). Rate and
behavior then use the hash of sub/tenant, not just the IP. The token and the
payload never go into the log.
Authorization is an opt-in callback per route (authorization = { socket = "unix:///run/authz.sock", action = "transfer:create", resource = "path.account_id", fail_mode = "closed" }). Without the block the route stays
as it is today: JWT bindings if they exist, otherwise the proxy does not decide ownership.
With the block, Ferroada asks the application's service — Unix socket only,
50 ms timeout (200 ms cap) — whether that jwt.sub may perform the action on that
resource. The response is { "allow": true|false }. Socket down or timeout:
fail_mode = closed returns 403; open lets the request through and increments the metric.
Ferroada does not embed OPA/Cedar and does not guess whether the object belongs to the
user — the contract is with the app's service. The event carries neither token nor body.
GraphQL is opt-in per site/route (graphql = { max_depth = 8, max_operations = 5, introspection = false }).
Without the block, POST /graphql only gets HTTP/WAF/OpenAPI. With the block, the
proxy parses the AST (JSON {"query":...}, array batch, or
application/graphql) and enforces caps on depth, complexity, aliases,
fragments and number of operations. Introspection (__schema / __type)
starts disabled in production. Persisted queries are a local allowlist of
SHA-256 hashes; persisted_only = true rejects ad-hoc queries outside the list. The cost
quota uses jwt.sub if the site has JWT, otherwise the IP. A failed parse becomes
ParseError and follows the route's policy (403 on require_complete). The event
reports which limit was exceeded (depth, aliases, …), never the query.
gRPC is opt-in per site (grpc = { descriptor = "./api.pb", allow = ["pkg.Service/Method"], max_message_bytes = "64KiB", reflection = false }).
Without the block, application/grpc (and +proto) follows the protocol matrix — currently
deny, or bypass-explicit if the operator changes it; the proxy does not start
inspecting on its own. With the block, the FileDescriptorSet is compiled at startup.
A method outside the allowlist returns 403 and zero bytes reach the origin. A per-message
size (read from the frame's 5-byte prefix) above max_message_bytes
also returns 403. The cap counts the payload; the frame on the wire is 5 bytes larger, so
a 64KiB message does not fit in the 64KiB HTTP replay without max_decoded_body. Reflection (grpc.reflection.v1 / ServerReflectionInfo) starts
disabled; it only passes with reflection = true. If the client does not send
grpc-timeout, the proxy injects the cap (default 10s) or denies if
require_deadline = true. Protobuf that does not match the descriptor becomes
ParseError and follows the route's policy. The event carries service/method, never
the payload. DLP on protobuf fields does not exist in this scope.
The body of any allowed method is inspected before it reaches the backend.
The proxy reads all chunks, applies the MAX_BODY_SIZE cap to
Transfer-Encoding: chunked as well, limits the sum of concurrent buffers with
WAF_MAX_IN_FLIGHT_BYTES and, before reading the body, conservatively reserves
the limit for both concurrent copies (local inspection + Pingora replay). If the
client sent Content-Encoding: gzip
or deflate, the WAF inflates a copy only for inspection — the original bytes
go to the upstream only after the decision. In Pingora 0.8.1, the safe replay
before the upstream is limited to 64KB; that is why MAX_BODY_SIZE is also
limited to 65536 bytes, except on require_complete routes with
max_decoded_body — there the three caps (bytes on the wire, WAF text, inflate)
rise together to that number, the body goes to a spool and the origin only receives it
after the outcome. Without max_decoded_body the process does not create
/var/lib/ferroada/spool. The inspection records complete, truncated,
unsupported_encoding, unsupported_content_type, parse_error,
budget_exceeded or timed_out. A giant gzip is
cut off at the route's inflate cap (256KB by default), so a zip bomb does not
exhaust memory. Sensitive routes can require complete inspection and reject
any other state.
JSON is parsed to expose escapes such as <; form-urlencoded handles + and
percent-encoding; textual GraphQL goes through the same text canonicalization.
gRPC/binary, unknown encodings and non-textual multipart are explicitly marked
as incomplete inspection, so fail-closed routes do not forward them by
mistake.
Signature rules catch the known attack; the behavior score catches whoever is looking for one. Each IP accumulates points per suspicious action and loses 5 points per second of good behavior:
| Signal | Points |
|---|---|
| WAF block | +20 |
| 401 response | +5 |
| 404 response | +3 |
| Request without User-Agent | +8 |
| Path scan (>30 unique paths/min) | detected |
| User-Agent rotation (>3 UAs) | detected |
Past 50 points, the client is slowed down; past 80, it is banned for 10 minutes. The
identity includes site and network address; IPv6 is grouped by /64 to prevent
cheap rotation within the same prefix. The
thresholds are configurable, tracking is capped at 50 thousand IPs with LRU
cleanup, and the decay, scoring and ban logic has unit tests
(src/behavioral.rs).
| Protection | What it does | Response |
|---|---|---|
| Security Headers | Injects X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-Permitted-Cross-Domain-Policies into every response | Automatic headers |
| Server Header Stripping | Removes Server, X-Powered-By, X-AspNet-Version, X-Debug-Token, X-Runtime |
Infrastructure details hidden |
| HTTP Method Restriction | Blocks TRACE, CONNECT and unconfigured methods | 405 Method Not Allowed |
| Request Size Limiting | Limits the body to 64KB before the upstream and the URI to 8KB | 413 / 414 |
| Host Validation | Validates the Host header against the ALLOWED_HOSTS allowlist (prevents DNS rebinding) |
421 Misdirected Request |
| HTTPS Enforcement | Redirects HTTP → HTTPS when TLS is configured + injects HSTS | 301 Moved Permanently |
| Secure dashboard | Listens on loopback by default; an external bind requires a token, OIDC or mTLS | 401 Unauthorized |
| Multi-encoding Protection | Decodes URL and headers recursively (max. 8x), including %uXXXX and + as a space in the query |
Prevents %2525252e bypass, percent-encoded XSS in headers |
| Upstream timeouts | HttpPeer with connect/read/write; the client body has its own deadline |
A dead upstream does not hold the worker |
| Rate limit with eviction | Sliding window per site/network; expired keys leave the map; configurable cap | A flood of identities does not grow memory forever |
| Trusted proxies | Only accepts XFF/X-Forwarded-Proto from networks in TRUSTED_PROXIES |
Prevents spoofing and a rate limit shared through the load balancer |
| Limits and backpressure | Limits headers, in-flight requests, keep-alive and writes to the client | Rejects exhaustion early with 431/503 |
| Supply chain in CI | cargo audit in the Docker build; fuzz (waf/protocol/dlp) and CycloneDX SBOM on every pull request |
The job goes red if the audit finds a CVE or the fuzzer crashes |
Conservative defaults (still need to be validated against the application):
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
X-XSS-Protection: 0
X-Permitted-Cross-Domain-Policies: none
X-Frame-Options can be changed with FRAME_OPTIONS or preserved from the
upstream with FRAME_OPTIONS=off; SAMEORIGIN can break legitimate embeds from
another origin.
Conditional (only enabled when you explicitly configure them):
Strict-Transport-Security → only when FORCE_HTTPS=true
Content-Security-Policy → only when CSP_POLICY is set
Permissions-Policy → only when PERMISSIONS_POLICY is set
Removed from the response (always):
Server, X-Powered-By, X-AspNet-Version, X-Debug-Token, X-Runtime
| Sensitive data | Detected pattern | Masked result |
|---|---|---|
| CPF | 390.533.447-05 (check digit) |
***.***.***-** |
| CNPJ | 04.252.011/0001-10 (check digit) |
**.***.***/****-** |
| Card | 13–19 digits with Luhn | digits become * |
| Bearer Token | Bearer eyJhbGciOi... |
Bearer [REDACTED] |
CPF (Brazilian individual taxpayer ID) and CNPJ (Brazilian company ID) values with a wrong check digit are not masked. JSON paths ($.user.cpf) are
opt-in in the TOML per site or route; without them blob DLP still applies. DLP_ACTION=block
does not release a single byte to the client before inspection finishes.
If the backend leaks a CPF or token in a textual identity, gzip,
deflate or brotli response, Ferroada decompresses it within a limit, masks it and recompresses it.
Brotli without an inflate budget is an observable skip, not "clean". When it may
transform a response, it removes Content-Length, ETag, Content-MD5,
Digest, Content-Digest and Repr-Digest, since they would no longer represent the
bytes sent. SSE, gRPC, NDJSON, WebSocket and signed responses pass through without
transformation so the protocol is not corrupted; each skip becomes an observable event.
Range requests are removed before the upstream and unexpected partial textual
responses are rejected, preventing a 206 from bypassing the masking. The buffer is limited to
1MB per response and 64MB per process by default.
The binary serves the API at http://localhost:9000: /healthz, /readyz,
/api/metrics and /metrics. GET / is not a dashboard — just a plain-text index.
With DASHBOARD_TOKEN, login is POST /api/login (token in the body, never in the
query) and opens a short session in an HttpOnly cookie. Outside loopback, a token
alone is not enough: enable OIDC (authorization code + PKCE) or mTLS (client
certificate). viewer reads metrics; operator can POST /api/reload with the session's CSRF
or Bearer. Without DASHBOARD_OIDC_* and without a client CA, the current behavior
(token) remains. This is not production-grade.
The Next dashboard (web/) is the interface: Overview and Events, refreshed every 5 s. If the proxy is not running, the dashboard shows zeros and unavailable — it does not make up traffic. Demo data only with FERROADA_ALLOW_DEMO=true.
cd web
npm install
cp .env.example .env.local
npm run dev # http://localhost:3100FERROADA_URL (default http://127.0.0.1:9000) and FERROADA_TOKEN (if
DASHBOARD_TOKEN is set) stay on the Next server, not in the browser. The
dashboard only delivers metrics after the visitor enters FERROADA_WEB_TOKEN;
that separate credential stays in the tab's sessionStorage and is sent as a Bearer
only to the same Next server.
These vulnerabilities are application logic issues and must be fixed in the backend code — a proxy that promised to solve them would be lying:
| Vulnerability | Why a proxy does not solve it |
|---|---|
| IDOR (Insecure Direct Object Reference) | JWT bindings check claim == path/body. Ownership (may this principal access this object?) can only be answered by the app's authorization service, via the opt-in Unix socket. Without the block, the proxy does not invent that decision. |
| Mass Assignment | Only the backend knows which fields are allowed in each request |
| Race Condition | Concurrency control is the responsibility of the database/backend |
| Weak JWT / secret | Backend authentication configuration |
| Plain-text passwords / unsalted hashes | A storage decision in the database |
| Role manipulation | Authorization is backend business logic |
| Social engineering | Human factor — no software solves it |
| Game hacking / WebSocket | Ferroada does not inspect WebSocket traffic |
Ferroada is the first line of defense (infrastructure), not the only one. For complete security, the backend needs its own validation.
A lab on your machine. For production: copy a pack from deploy/topologies/ — the dashboard stays on loopback and is not published to the internet.
docker build -t ferroada .docker run -d \
-e TARGET_URL=http://host.docker.internal:8080 \
-e RUST_LOG=info \
-e DASHBOARD_BIND=127.0.0.1 \
-e DASHBOARD_TOKEN='troque-por-um-token-longo' \
-e RATE_LIMIT_MAX=100 \
-e RATE_LIMIT_WINDOW=60 \
-p 3000:3000 \
--name ferroada \
ferroadaThe dashboard listens on 127.0.0.1:9000 inside the container. In the lab, get in with docker exec or publish it only on the host's loopback. In production use the tunnel ssh -L 9000:127.0.0.1:9000.
# Normal request (passthrough)
curl -i http://localhost:3000/
# SQL Injection → 403
curl -i "http://localhost:3000/?id=1 UNION SELECT * FROM users"
# XSS → 403
curl -i "http://localhost:3000/?q=<script>alert(1)</script>"
# Path Traversal → 403
curl -i "http://localhost:3000/../../etc/passwd"
# Sensitive path → 403
curl -i http://localhost:3000/.env
curl -i http://localhost:3000/.git/config
# Body injection (POST) → 403
curl -i -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"user":"admin","pass":"x\" OR 1=1--"}'
# Rate limit → 429 (after 100 requests)
for i in $(seq 1 105); do
curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/
done
# Dashboard (only if you published the host's loopback)
curl -H 'Authorization: Bearer troque-por-um-token-longo' \
http://127.0.0.1:9000/api/metricsThe binary is a Pingora process. It does not run on Hostinger or as a function on Vercel. Each folder in deploy/topologies/ is a fail-closed pack: toml, env, Compose, systemd unit, origin-lock and an honest VISIBILIDADE.md.
| Mode | When to use |
|---|---|
vps-site |
VPS with the site (frontend + backend) |
vps-api |
VPS with the API only |
vps-supabase-selfhost |
API + self-hosted Supabase (Kong) |
vps-supabase-cloud |
API on the VPS, Supabase Cloud |
vps-full |
Everything on the VPS |
hostinger-origin |
Static site on Hostinger; Ferroada on a VPS in front |
vercel-origin |
App on Vercel; Ferroada on a VPS in front |
cdn-edge |
Internet → CDN → Ferroada → backend (recommended) |
Helm |
Kubernetes (rootless pod; the dashboard is not a Service) |
Start with the mode's CHECKLIST.md. TLS: Ferroada terminates it if there is a fullchain.pem; otherwise Caddy sits in front. The two never publish 443 at the same time. Edge CIDRs: deploy/cidrs/ (dated snapshot, no fetch inside the process).
ferroada init --topology vps-api --origin http://127.0.0.1:8080 --public-host api.exemplo.com --non-interactive
ferroada init --topology cdn-edge --origin http://127.0.0.1:8080 --public-host api.exemplo.com --trusted-proxies auto --non-interactive
ferroada healthcheck # GET 127.0.0.1:9000/healthz; exit 0/1. Distroless has no curl.
ferroada reload # SIGHUP: tries the new TOML; if it fails, last-known-good
ferroada policy compile --config ferroada.toml -o ferroada.policy.json
ferroada policy sign --key priv.pem --in ferroada.policy.json -o ferroada.policy.sig--trusted-proxies auto copies the snapshot in deploy/cidrs/ (embedded in the binary). Zero HTTP. To update the list: ferroada cidrs update (HTTP only in this command; --cidrs-from-network does not exist in init). --listen-mode privileged emits :80/:443 and CAP_NET_BIND_SERVICE; proxied keeps the process on 127.0.0.1:3000 with Caddy in front.
All configuration is done through environment variables:
| Variable | Default | Description |
|---|---|---|
TARGET_URL |
(required*) | URL of the upstream system — single-site mode (e.g. http://meu-sistema:8080) |
RUST_LOG |
info |
Log level (debug, info, warn, error) |
RATE_LIMIT_MAX |
100 |
Maximum requests per site/network within the window |
RATE_LIMIT_WINDOW |
60 |
Time window in seconds |
RATE_LIMIT_MAX_IPS |
50000 |
Cap on risk identities in the map (eviction above it) |
REPLICA_COUNT |
1 |
Conservatively splits the rate/behavior budgets across replicas |
RISK_SESSION_COOKIE |
session |
Session cookie included (hashed) in the risk identity |
RISK_API_KEY_HEADER |
X-API-Key |
API key header included (hashed) in the risk identity |
UPSTREAM_CONNECT_TIMEOUT |
5 |
Seconds for connect() to the backend |
UPSTREAM_READ_TIMEOUT |
30 |
Maximum seconds reading the backend's response |
UPSTREAM_WRITE_TIMEOUT |
30 |
Maximum seconds writing to the backend |
BODY_READ_TIMEOUT |
10 |
Maximum seconds reading the client's body |
DOWNSTREAM_WRITE_TIMEOUT |
30 |
Maximum seconds writing the response to the client |
DOWNSTREAM_KEEPALIVE_SECS |
30 |
Idle timeout of the HTTP/1 connection with the client |
DOWNSTREAM_KEEPALIVE_REQUESTS |
100 |
Maximum requests reusing the same HTTP/1 connection |
MAX_IN_FLIGHT_REQUESTS |
1024 |
Cap on concurrent requests; the excess receives 503 |
CONNECTION_RATE_MAX |
60 |
New connections accepted per IP within the window, before TLS/HTTP |
CONNECTION_RATE_WINDOW |
1 |
Window of the connection limit, in seconds |
CONNECTION_RATE_MAX_IPS |
50000 |
Cap on IPs tracked by the connection filter |
GLOBAL_CONNECTION_RATE_MAX |
10000 |
New global connections accepted per window, before TLS/HTTP |
MAX_ACTIVE_CONNECTIONS |
10000 |
Cap on active downstream sessions, including those waiting for HTTP/1 headers |
MAX_HEADER_COUNT |
100 |
Maximum headers per request |
MAX_HEADER_BYTES |
65536 |
Maximum sum of header names and values |
MAX_UPSTREAM_RETRIES |
0 |
Retries after the first attempt; capped at 3. With several origins, GET/HEAD/OPTIONS get at least 1 retry if the origin resets before responding. POST/PATCH are not retried. |
PROXY_THREADS |
Available CPUs (max. 64) | Threads per service |
GRACE_PERIOD_SECS |
5 |
Wait before the final phase of graceful shutdown |
GRACEFUL_SHUTDOWN_TIMEOUT_SECS |
30 |
Limit of the final phase of graceful shutdown |
TLS_CERT_PATH |
(optional) | Path to the TLS certificate (fullchain.pem) |
TLS_KEY_PATH |
(optional) | Path to the TLS private key |
PROXY_LISTEN |
0.0.0.0:3000 |
HTTP listener address |
TLS_LISTEN |
0.0.0.0:3443 |
HTTPS listener address (only with TLS_CERT_PATH/TLS_KEY_PATH) |
DASHBOARD_PORT |
9000 |
Monitoring dashboard port |
DASHBOARD_BIND |
127.0.0.1 |
Dashboard IP; a non-loopback bind requires a token (or OIDC/mTLS) |
DASHBOARD_TOKEN |
(empty on loopback) | Bearer token. FERROADA_PRODUCTION=true requires it even on loopback, even with OIDC/mTLS. Without production, a bind outside loopback accepts a token or OIDC/mTLS. On loopback the token alone remains valid. |
DASHBOARD_OIDC_ISSUER / DASHBOARD_OIDC_CLIENT_ID / DASHBOARD_OIDC_REDIRECT_URI |
(unset = off) | Opt-in OIDC (authorization code + PKCE). Without these and without DASHBOARD_MTLS_CA the dashboard remains token-only. |
DASHBOARD_OIDC_CLIENT_SECRET |
(optional) | Confidential secret; never goes in the URL. PKCE runs anyway. |
DASHBOARD_OIDC_ROLE_CLAIM |
ferroada_role |
ID token claim mapped to viewer or operator. |
DASHBOARD_OIDC_OPERATORS / DASHBOARD_OIDC_VIEWERS |
operator / viewer |
Claim values that grant each role. Without operator, POST /api/reload is 403. |
DASHBOARD_MTLS_CA |
(unset) | CA for client certificates. Requires DASHBOARD_TLS_CERT_PATH/DASHBOARD_TLS_KEY_PATH. The handshake verifies the cert if the client sends one. Outside loopback the API requires the cert (or an OIDC session). |
DASHBOARD_MTLS_OPERATORS / DASHBOARD_MTLS_VIEWERS |
(empty = viewer) | CNs with the operator/viewer role. |
DASHBOARD_SESSION_TTL_SECS |
900 |
HttpOnly, SameSite=Strict cookie, Secure if the dashboard speaks TLS. Min. 60, max. 8h. |
DASHBOARD_AUDIT_LOG |
(unset) | Append-only JSONL file (login, reload, auth failure). Does not record the token or the OIDC code. |
DASHBOARD_API_RATE_MAX / DASHBOARD_API_RATE_WINDOW |
60 / 60 |
Cap on requests to /api per IP and window in seconds. |
FERROADA_PRODUCTION |
(unset) | If true, the token is required even on 127.0.0.1. GET / on :9000 is only the plain-text index; /api/metrics and /metrics require a session, Bearer (loopback) or mTLS. |
FERROADA_POLICY_PUBKEY |
(unset) | Ed25519 public key (PEM, hex or base64). Opt-in: without it a bare TOML starts. With it, the snapshot needs a valid ferroada.policy.sig |
FERROADA_POLICY_SIG |
ferroada.policy.sig |
Ed25519 signature of the snapshot (ferroada policy compile) |
FERROADA_PID_FILE |
ferroada.pid |
Written at boot; ferroada reload --pid-file reads from here |
OTEL_EXPORTER_OTLP_ENDPOINT / FERROADA_OTLP_ENDPOINT |
(unset = off) | HTTP base of the OTLP Collector (http://127.0.0.1:4318). Without it the process opens no extra socket. HTTPS and userinfo in the URL are rejected; the export never sends Authorization. |
OTEL_EXPORTER_OTLP_TIMEOUT |
1000 |
OTLP POST timeout in milliseconds (5s cap). A dead Collector increments ferroada_otel_export_failed_total and the proxy keeps going. |
TRUSTED_PROXIES |
(empty) | CIDRs allowed to send XFF/X-Forwarded-Proto |
PROXY_PROTOCOL |
false |
PROXY v2 parser (not a Pingora flag). true only behind HAProxy/nginx/Caddy/TCP NLB. An invalid prefix rejects the connection. Cloudflare does not turn this on |
CLIENT_IP_ORDER |
proxy_protocol,x-forwarded-for |
Order of IP sources. Forwarded (RFC 7239) is only used if listed; the default still strips hop-by-hop |
CLIENT_IP_HEADER |
(unset) | Extra header (e.g. cf-connecting-ip) only if CLIENT_IP_ORDER includes header |
FERROADA_ALLOW_DEMO |
(unset) | If true, the Next dashboard makes up metrics while the proxy is down. Production: do not enable |
SECURITY_HEADERS |
true |
Inject secure headers into responses (nosniff, X-Frame, Referrer) |
FRAME_OPTIONS |
SAMEORIGIN |
X-Frame-Options value; off preserves the upstream's |
CSP_POLICY |
(disabled) | Content-Security-Policy — only enable it if you know what you are doing |
PERMISSIONS_POLICY |
(disabled) | Permissions-Policy — only enable it if you don't use camera/mic/geolocation |
ALLOWED_METHODS |
GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS |
Allowed HTTP methods |
MAX_BODY_SIZE |
65536 |
Maximum body size; larger values are capped at 64KB by Pingora 0.8.1's safe replay. Routes with max_decoded_body use that cap instead. |
SPOOL_DIR |
(unset = off) | Spool directory; only validated/cleaned if some route has max_decoded_body. Default in that mode: /var/lib/ferroada/spool |
SPOOL_MAX_BYTES |
536870912 |
Aggregate cap on spool bytes (512 MiB) |
SPOOL_MAX_FILES |
256 |
Cap on concurrent spool-* files |
WAF_MAX_IN_FLIGHT_BYTES |
67108864 |
Global reservation for the local copy, the Pingora replay and the decompressed inspection buffer (256 KiB + sentinel byte) |
MAX_URI_LENGTH |
8192 |
Maximum URI size in bytes (8KB) |
ALLOWED_HOSTS |
(disabled) | Allowlist of domains in the Host header (e.g. meusite.com,www.meusite.com) |
FORCE_HTTPS |
false |
Redirect HTTP → HTTPS (requires TLS to be configured) |
HTTPS_REDIRECT_HOST |
(empty) | Trusted public host for redirects in single-site/default mode |
DLP_ENABLED |
true |
Enables DLP inspection of CPF/CNPJ/card numbers and tokens in responses |
DLP_ACTION |
redact if unset and DLP_ENABLED=true |
monitor (counts, does not mask), redact (masks), block (502 if PII is found or the buffer overflows). Production packs emit monitor |
ORIGIN_SECRET_HEADER |
X-Ferroada-Origin if only ORIGIN_SECRET exists |
Name of the header injected upstream. Unset with an empty secret = not injected |
ORIGIN_SECRET |
(unset) | Value of the origin header. Never logged. Empty = not injected |
DLP_MAX_RESPONSE_BYTES |
1048576 |
Maximum buffer per textual response inspected by DLP |
DLP_MAX_IN_FLIGHT_BYTES |
67108864 |
Global memory budget for concurrent DLP buffers |
BAD_BOT_ENABLED |
true |
Blocking by User-Agent signature (sqlmap, nikto, ...) |
WAF_PROFILE |
generic |
generic, wordpress (monitor) or strict for WordPress operational paths |
WAF_REQUIRE_COMPLETE_PATHS |
(empty) | Fail-closed prefixes in single-site mode, comma-separated |
WAF_ENGINE |
native |
native (L0 only) or coraza (L1 sidecar; requires Unix socket and ready probe) |
WAF_SIDECAR_SOCKET |
/run/coraza/waf.sock |
Socket of the L1 sidecar |
WAF_SIDECAR_TIMEOUT_MS |
500 |
Cap on the L1 call. 20 ms would return 403 on clean traffic during the CRS cold start |
BEHAVIORAL_ENABLED |
true |
Behavior score per site/network |
BEHAVIORAL_SLOW_THRESHOLD |
50 |
Points before the IP starts being slowed down |
BEHAVIORAL_BLOCK_THRESHOLD |
80 |
Points to ban the IP |
BEHAVIORAL_BAN_SECS |
600 |
Ban duration in seconds |
BEHAVIORAL_MAX_IPS |
50000 |
Cap on tracked IPs (LRU cleanup above it) |
* TARGET_URL is required only in single-site mode. In multi-site mode, use ferroada.toml.
To protect several backends with a single deployment, create a ferroada.toml file:
# Complete and commented: ferroada.toml.example
[[sites]]
hosts = ["meusite.com", "www.meusite.com"]
backend = "http://backend-a:8080"
# backend = ["http://a:8080", "http://b:8080"] # round-robin + health GET /health/ready
waf_profile = "wordpress"
require_complete_waf_inspection = ["/api/payment", "/api/admin/"]
[[sites]]
hosts = ["api.meusite.com"]
backend = "http://backend-b:3000"
# Opt-in. Without the block, the site stays HTTP/WAF/DLP only.
# openapi = { spec = "./openapi.yaml", unknown_endpoint = "observe" }
# jwt = { jwks = "https://auth.exemplo.com/.well-known/jwks.json", issuer = "https://auth.exemplo.com", audience = "api.meusite.com", bindings = ["jwt.sub == path.account_id"] }
# abuse = { stuffing_max = 10, challenge = "pow" }
# [[sites.routes]]
# prefix = "/accounts/"
# authorization = { socket = "unix:///run/authz.sock", action = "account:read", resource = "path.account_id", fail_mode = "closed" }Routing is done automatically by each request's Host header. Unconfigured
hosts receive 421 Misdirected Request.
Rules:
- If
ferroada.tomlexists → multi-site mode (ignoresTARGET_URL) - If it does not exist → uses
TARGET_URLas before (backward compatible) - Rate limit and behavioral scoring are isolated per site; IPv6 is aggregated by /64
- WAF/DLP do not keep decisions across sites; metrics remain global to the process
- The
ALLOWED_HOSTSenv var still works as an additional restriction
# Multi-site with Docker (lab). Production: deploy/topologies/
docker run -d \
-v ./ferroada.toml:/ferroada.toml:ro \
-e DASHBOARD_BIND=127.0.0.1 \
-e DASHBOARD_TOKEN='troque-por-um-token-longo' \
-p 3000:3000 \
ferroadaFor production, configure TLS to accept HTTPS on port 3443:
docker run -d \
-e TARGET_URL=http://backend:8080 \
-e TLS_CERT_PATH=/certs/fullchain.pem \
-e TLS_KEY_PATH=/certs/privkey.pem \
-e DASHBOARD_BIND=127.0.0.1 \
-e DASHBOARD_TOKEN='troque-por-um-token-longo' \
-v /etc/letsencrypt/live/meudominio:/certs:ro \
-p 443:3443 \
-p 80:3000 \
ferroadaservices:
ferroada:
build: ./ferroada
ports:
- "80:3000"
environment:
- TARGET_URL=http://app:8080
- RATE_LIMIT_MAX=100
- DASHBOARD_BIND=127.0.0.1
- DASHBOARD_TOKEN=${FERROADA_DASHBOARD_TOKEN}
depends_on:
- app
app:
image: seu-sistema:latest
expose:
- "8080"The dashboard is not published to the internet. Tunnel: ssh -L 9000:127.0.0.1:9000. In the Docker packs the mapping is only 127.0.0.1:9000 on the host. Complete packs: deploy/topologies/.
services:
ferroada:
build: ./ferroada
ports:
- "80:3000"
environment:
- DASHBOARD_BIND=127.0.0.1
- DASHBOARD_TOKEN=${FERROADA_DASHBOARD_TOKEN}
volumes:
- ./ferroada.toml:/ferroada.toml:ro
depends_on:
- site-a
- site-b
site-a:
image: meusite:latest
expose:
- "8080"
site-b:
image: outrosite:latest
expose:
- "3000"location / {
proxy_pass http://localhost:3000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}Set TRUSTED_PROXIES to the actual CIDR of the Nginx network. Without it,
Ferroada ignores XFF and uses the remote socket. A peer outside that allowlist
cannot change its identity by sending its own headers.
Point the ALB/Target Group at port 3000 of the Ferroada container instead of directly at the backend.
The recommended design is Internet → Cloudflare/CDN/LB → Ferroada → backend.
DDoS stays with the CDN. The one looking at your API's requests is Ferroada, on your server.
Restrict the origin to accept only the edge (firewall, private tunnel or
mTLS) and register its CIDRs in TRUSTED_PROXIES. Ferroada does not replace
Anycast or L3/L4 DDoS mitigation: if the machine's link saturates, the process
has no way to respond with 429.
State remains local to each replica. With multiple replicas, configure
REPLICA_COUNT: Ferroada splits the rate budgets and the behavior
thresholds to keep the aggregate limit conservative even without a store. Bans and
history are not replicated; keep client affinity or apply the distributed
layer at the edge. Logs go to stdout for the platform's collector.
GET /healthz confirms the process; GET /readyz checks the backends with a deadline
and returns 503 if any of them is unavailable. These routes do not require a token;
/api/metrics and /metrics use the dashboard's authentication.
src/
├── proxy.rs # Pipeline: Host → route → method/size → rate → behavioral → abuse → WAF L0/L1 → OpenAPI/JWT/GraphQL/gRPC/authz → origin → DLP
├── protocol.rs # inspect/deny/monitor/bypass matrix (and quarantine = 403 + tag)
├── waf.rs / waf_engine.rs / waf_l1.rs # L0 regex; L1 Coraza sidecar
├── spool.rs # Body > 64 KiB only on a fail-closed route with max_decoded_body
├── openapi.rs / jwt.rs / graphql.rs / grpc.rs / authz.rs
├── abuse.rs # Per-fingerprint quota (opt-in)
├── dlp.rs # Blob + JSON path; monitor/redact/block
├── policy.rs # Signed snapshot and last-known-good
├── origin.rs # Multiple backends, health, circuit
├── otel.rs # Opt-in OTLP
├── proxy_protocol.rs / client_ip.rs
└── dashboard.rs / dashboard_oidc.rs / dashboard_auth.rs
deploy/topologies/ # VPS / Hostinger / Vercel / CDN packs
deploy/helm/ferroada/
web/ # Next dashboard (zeros if the proxy goes down, no demo)
fuzz/ # waf, protocol, dlp — runs in PR CI
Client
│
▼
[HTTPS Redirect] ──301──→ Client (if FORCE_HTTPS and HTTP request)
│ ok
▼
[Host Check] ──421──→ Client (Host not allowed, DNS rebinding)
│ ok
▼
[Route Backend] ──421──→ Client (Host without a configured site, multi-site)
│ ok
▼
[Method Check] ──405──→ Client (TRACE, CONNECT, etc.)
│ ok
▼
[Size Check] ──413/414──→ Client (body/URI too large)
│ ok
▼
[Rate Limiter] ──429──→ Client (Too Many Requests)
│ ok
▼
[Behavioral Score] ──403──→ Client (IP above the ban threshold)
│ ok
▼
[Abuse quotas] ──403──→ stuffing/enumeration (opt-in; same fingerprint, different IPs)
│ ok
▼
[WAF L0 + L1] ──403──→ regex and, if enabled, the Coraza sidecar
│ ok
▼
[OpenAPI / JWT / GraphQL / gRPC / authz] ──403/401──→ only if the site has the block in the TOML
│ ok
▼
[Upstream Backend]
│
▼
[Strip Server Headers] → Removes Server, X-Powered-By, etc.
│
▼
[Inject Security Headers] → HSTS, CSP, X-Frame-Options, etc.
│
▼
[DLP: Response Masking] → CPF/CNPJ/card numbers and tokens masked
│
▼
Client (clean, hardened response)
- Pingora 0.8 (Cloudflare) — high-performance HTTP proxy
- Rust — single binary, no runtime, no garbage collector
- Distroless — Docker image with no shell and no package manager (~20MB)
- Zero external dependencies — no need for Redis, a database or any auxiliary service
- Not production-grade yet — lacks an external audit and LTS. See SECURITY.md.

