no-forwarded-ip-auth
error
Rust
What it catchesReading a forwarded-IP (Internet Protocol — the address a packet claims to come from) header (X-Forwarded-For / X-Real-IP) in Rust.
How the match worksAn any of four patterns: $H.get("x-forwarded-for"), $H.get("X-Forwarded-For"), and the two x-real-ip cases. Both letter cases are listed because header lookups are usually case-insensitive but the source text is not.
Why I have itWith no trusted reverse proxy, the client sets X-Forwarded-For and X-Real-IP, so a caller can put anything there. Feed one into the auth decision and you have an unauthenticated bypass: an attacker just claims a tailnet address. The real boundary is the TCP peer via ConnectInfo<SocketAddr> -> require_tailnet. If you read the header for logging, keep it out of any allow/deny. Error severity: this is the sharpest edge in the codebase.
Documented limitsThe four patterns are exact $H.get("...") forms in both letter cases. A different header spelling, a variable that holds the header name, or any indirection through a helper evades the match.
no-forwarded-ip-auth-ruby
error
Ruby
What it catchesThe Ruby sibling: reading a forwarded IP in Rails, including request.remote_ip.
How the match worksAn any over env["HTTP_X_FORWARDED_FOR"], env["HTTP_X_REAL_IP"], request.headers["X-Forwarded-For"], request.headers["X-Real-IP"], and, deliberately, request.remote_ip.
Why I have itrequest.remote_ip looks safe but ActionDispatch::RemoteIp trusts X-Forwarded-For under the hood, so it is itself a forwarded-IP footgun. Source identity must come from env["REMOTE_ADDR"], the actual TCP peer, which is what TailnetGate reads on purpose.
Beforeip = request.remote_ip # trusts X-Forwarded-For
ip = env["HTTP_X_FORWARDED_FOR"]
Afterip = env["REMOTE_ADDR"] # the real TCP peer
Documented limitsThe five patterns are exact: the two env[...] reads, the two request.headers[...] reads, and request.remote_ip. A different casing, a variable holding the key, or an indirection through a helper evades them.
axum-router-missing-tailnet-gate
warning
Rust
What it catchesAn axum router-builder function that wires routes but no require_tailnet gate.
How the match worksMatches a function_item that has Router::new() and has a $A.route($$) call, but does not has the text require_tailnet anywhere in it (a regex search over the function body). All three sub-checks use stopBy: end to scan the whole function.
rule (excerpt)rule:
kind: function_item
all:
- has: { stopBy: end, pattern: Router::new() }
- has: { stopBy: end, pattern: $A.route($$) }
- not: { has: { stopBy: end, regex: "require_tailnet" } }
Why I have itEvery agstaff server is tailnet-only; the boundary is a .layer(from_fn(auth::require_tailnet)) on the router. A router with routes and no gate is an open server. It's a warning, not an error, because the checks have known blind spots (below). A visible warning beats a hard block that trips on false alarms.
Documented limitsTwo documented limits. First, a require_tailnet left in a comment defeats the regex, a false negative. Second, a router split across builder functions (idiomatic axum scaling) false-positives on the sub-builder. Drop an inline // ast-grep-ignore there.
axum-serve-without-connect-info
warning
Rust
What it catchesServing an axum app with bare .into_make_service().
How the match worksTwo patterns: axum::serve($L, $X.into_make_service()) and $R.serve($X.into_make_service()).
Why I have itrequire_tailnet and agmemory's identity resolver extract ConnectInfo<SocketAddr> to learn the peer's address. The bare .into_make_service() doesn't attach that connection info, so the code compiles but the gate fails at runtime. It fails silently, which is the dangerous part. Use .into_make_service_with_connect_info::<SocketAddr>(). This is the serve-side companion to the router-gate rule.
Beforeaxum::serve(listener, app.into_make_service()).await?;
Afteraxum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
).await?;
agmemory-no-implicit-operator
warning
Rust
What it catchesagmemory operator status derived from "has a login" or "has a tag" instead of the operator allow-list.
How the match worksAn any of patterns built on Identity { $$, operator: ... }. The $$ matches the other struct fields. Then operator: is tested against $X.is_some(), $X.as_deref().is_some(), or !$X.is_empty(), all "any peer with a value = operator" shapes. The operator: true case is guarded by not inside an if_expression that checks $ADDR.is_loopback(), so a legitimate hardcoded operator on a loopback caller isn't flagged.
rule (excerpt)rule:
any:
- pattern: "Identity { $$, operator: $X.is_some() }"
- pattern: "Identity { $$, operator: !$X.is_empty() }"
- all:
- pattern: "Identity { $$, operator: true }"
- not: { inside: { stopBy: end, kind: if_expression,
has: { stopBy: end, pattern: "$ADDR.is_loopback()" } } }
Why I have itOperator status must come only from the AGMEMORY_OPERATORS allow-list via listed(value, operators), never from a peer that merely has a login or a tag. "Any logged-in or tagged peer is an operator" was the exact privilege escalation v2 removed, because seats share tag:chief.
Documented limitsOnly catches operator set inside the Identity {} literal. The canonical safe path computes let operator = ... above and passes it by field shorthand. A regression on that line is not caught. The note calls for a companion pattern (let operator = $X.is_some();) scoped to crates/agmemory/**. Self-aware by design; it's a partial tripwire, not a proof.
document-upload-must-sniff-mime
warning
Ruby
What it catchesAn ActiveStorage .file.attach() that takes filename or content type straight from the request.
How the match worksPattern $RECV.file.attach($$ARGS) that has a pair (a key/value in the options hash). The key is content_type or filename, and the value comes from params[$K] or $X.original_filename. The stopBy: end lets the key and value sit anywhere in the argument tree.
Why I have itThe CRM (Customer Relationship Management — the sales contact system) allow-list gate and the download Content-Type both derive from the stored MIME and filename. If a client can set those, it can mislabel an executable as a PDF (Portable Document Format) and slip past the gate. Sniff the actual bytes with Marcel::MimeType.for and File.basename the name (as Document.ingest! / sniff_mime do), then store the derived values.
Documented limitsThe value check deliberately omits $X.content_type, which would false-positive on the safe sniffed.content_type case, where the content type is already the sniffed result.
plan-html-sanitize-before-store
warning
Ruby
What it catchesUnsanitized HTML reaching a Rails response through html_safe or raw().
How the match worksAn any of four patterns: render html: $X.html_safe, render html: raw($X), render inline: $X, and a bare raw($X). The one sanctioned file, plans_controller.rb, is listed in ignores so it doesn't flag itself.
Why I have itAgstaff::PlanSanitizer sanitizes agent-authored plan HTML on write, and only PlansController#show renders it. Any other html_safe or raw() bypasses that single boundary and reopens a stored XSS (Cross-Site Scripting — injecting script into a page other users load) hole. Render through the sanctioned path, or sanitize first.
Documented limitsIt matches only the render-API call shapes (render html:, render inline:, and raw(...)). It cannot tell whether $X was already sanitized, so a value cleaned upstream still flags, and HTML that reaches the response by a different route is missed.
rails-no-csrf-disable
warning
Ruby
What it catchesCSRF protection turned off in a Rails controller.
How the match worksAn any over the disabling spellings: skip_before_action :verify_authenticity_token (with or without extra args), skip_forgery_protection, and protect_from_forgery with: :null_session.
Why I have itThe tailnet is not a CSRF boundary. A page open in any tailnet browser can forge a same-origin POST, and disabling the token check lets it through. Cookieless JSON endpoints belong under ActionController::API, which has no forgery protection to disable. So there's no reason to reach for skip_forgery_protection on a normal controller.
rails-tailnet-gate-installed
error
Ruby
What it catchesA Rails Application class that never installs TailnetGate at the front of the middleware stack.
How the match worksMatches a class that has config.load_defaults $V (the marker of a Rails application config) but does not has config.middleware.insert_before 0, TailnetGate. Both checks use stopBy: end to scan the class body.
Why I have itThe Tailscale source IP is the only auth boundary for these Rails apps: no Devise, no trusted proxy. If TailnetGate isn't at index 0, requests reach the app before anything checks where they came from. Error severity: a missing gate is a wide-open app.
Before# config/application.rb — MISSING the gate
class Application < Rails::Application
config.load_defaults 7.1
end
Afterrequire_relative "../lib/middleware/tailnet_gate"
class Application < Rails::Application
config.load_defaults 7.1
config.middleware.insert_before 0, TailnetGate
end