Skip to content

Edge, Rate Limits & Safety

Every figure on this page was measured on 2026-10-06 against the dev estate and the deployed build main-7266d6495829, or read out of the source that was running at the time. Where something could not be verified it says so instead of guessing.

That is the accurate answer, not a gap in the research. Three independent measurements:

One container per service. docker ps filtered on the ai.logrite.service label returns 13 Logrite containers and 13 distinct service names — no service appears twice:

ai-service analyzer backend data-prepper demo-seeder devdocs downtime-tracker
frontend ingest-shim mcp-server semantic-search warden webhook

No replicas declared. Grepping the generated stack for replicas, deploy: or mode: replicated across /opt/logrite-stack/*.yml returns one hit, and it is a comment about OpenSearch shard replicas in docker-compose.health.yml — unrelated to service scaling.

One origin per hostname. The tunnel’s own live configuration has 18 ingress rules, each pointing at exactly one loopback port. There is no upstream pool to balance across:

hostnameorigin
dev-app.logrite.aihttp://127.0.0.1:3000
dev-leapfrogapi.logrite.aihttp://127.0.0.1:9800
dev-analyzer.logrite.aihttp://127.0.0.1:9001
dev-aiservice.logrite.aihttp://127.0.0.1:9300
dev-mcp.logrite.aihttp://127.0.0.1:9500
dev-warden.logrite.aihttp://127.0.0.1:9600
… 12 more, same shape

Read it from the tunnel rather than from a config file — the tunnel is token-managed, so the ingress lives in the Cloudflare dashboard, but cloudflared logs the configuration it fetched:

Terminal window
ssh dev 'journalctl -u cloudflared --no-pager \
| grep -o "Updated to new configuration.*" | tail -1'

And nothing else is in the path. ss -ltnp shows nothing listening on :443 at all, and the only listener on :80 is tailscaled — not a Logrite proxy. cloudflared runs as a single process (pgrep -c cloudflared → 1), so there is not even tunnel-level redundancy from a second replica.

A single instance per service with no pool in front has one consequence worth stating plainly: a restart is a full outage for that service, not a drained instance. This is not a hypothetical — it was measured during this session’s own deploy. Immediately after the backend container was replaced, every API call returned:

HTTP 502 error code: 502

Every probe in that window answered 502 with error code: 502 as the entire body — no JSON envelope, nothing to branch on. The exact start was not timed, so no duration is claimed here; what is measured is the recovery: a poll for swagger-ui.html returning 302 first succeeded at 12:54:43 UTC, with the container reporting Up 53 seconds (healthy).

Requests do transit Cloudflare — confirmed from the response headers on dev-leapfrogapi.logrite.ai:

server: cloudflare
cf-ray: a465022bab71c84f-MRS
strict-transport-security: max-age=31536000 ; includeSubDomains
alt-svc: h3=":443"; ma=86400
cf-cache-status: DYNAMIC

So TLS termination, HSTS for a year including subdomains, and HTTP/3 are in place at the edge, and API responses are marked DYNAMIC (not cached).

The backend has three mechanisms that can answer 429, and they are not alternatives — each covers the gap the others leave:

countskeyed onstateanswers
PublicRateLimitFilterrequestssource IP, or a credential digestin-memory429 + Retry-After
AuthRateLimitFilterrequests under /api/v1/auth/** — credential 20/15min, one-time-code 15/15min, email 10/hour, backstop 120/15minsource IPin-memory429 + Retry-After
AuthAttemptService / OtpGuardwrong answersthe email address / the codepersisted429 + Retry-After

The distinction that makes all three necessary: a correct password has no attempt ceiling, so a credential-stuffing run spraying one guess each across ten thousand harvested addresses never trips AuthAttemptService — every address sits at 1 of 5 — while still costing ten thousand bcrypt verifications and ten thousand REQUIRES_NEW transaction writes. Conversely a rate limit cannot stop a patient attacker making five guesses an hour at one account, which is exactly what the attempt ceiling is for.

RateLimitRules enumerates the guarded paths; shouldNotFilter returns true for everything else. This is deliberate and load-bearing: the inverse design (meter everything, carve out exceptions) puts the cost of a forgotten carve-out on a live platform. Paths that cannot survive a request budget are therefore unreachable by any future edit to a limit — /actuator/** (the container’s own liveness probe), /internal/** (service-to-service), /api/v1/analytics/** (the OpenSearch proxy a single Analytics page load fans out across), /api/v1/runs/** (scan-progress polling) and /ws/** (the progress socket) appear in no rule.

groupbudgetkeyed perpaths
EMAIL20/hour per IP and 10/hour per recipientIP + recipient digest/auth/signup, /auth/pre-register, /auth/resend-otp, /auth/forgot-password, /auth/mfa/send-otp, /user/password/**
CREDENTIAL300/hourIP/auth/login, /auth/verify-otp, /auth/verify-magic, /auth/mfa/verify, /auth/mfa/passkey/**, /auth/reset-password/**, /auth/workspace, /user/password-reset, /workspace/invitations/**
EXPENSIVE240/hourcredential digest/analyzer, /generator, /code-analysis, /codeFixes, /scan/initiate
CI600/hourCI key digest/gatekeeper/check

Three of those choices are not the obvious ones, and each has a reason:

  • EXPENSIVE keys on the bearer, not the IP. The ai-service forwards each end user’s own token from a single container address, so an IP key would pool every Cortex user on the platform into one bucket and starve them all for one caller’s retry loop.
  • CI gets its own knob an order of magnitude higher. A 429 on POST /gatekeeper/check is a red build on a customer’s pull request, not a button they can click again. A whole organisation’s runners also share one egress address, and GitHub-hosted runners have no stable one — so it keys on the customer key presented as a bearer.
  • EMAIL has a second ceiling on the recipient. The per-IP budget caps what one source can send; it cannot cap what one inbox receives, because the source is the attacker’s to vary. The damage — somebody else’s inbox, and this platform’s sending-domain reputation — is keyed on the recipient.

Every bucket key is stored as the hex of the first 16 bytes of SHA-256, never the raw value. A map of live session tokens or customer email addresses sitting in heap for the lifetime of the process is a credential store nobody asked for, and reachable from a heap dump; a 128-bit digest is just as unique a bucket and carries nothing back.

The attempt ceiling. Six POST /api/v1/auth/login with a deliberately wrong password against a non-existent address:

1:401 2:401 3:401 4:401 5:401 6:429
{"message":"Too many failed attempts. Try again in 10 minute(s), or reset your password.","status":429}

Five wrong answers, then a ten-minute lock — LOGIN_MAX_ATTEMPTS = 5, security.auth-attempt.lock-minutes:10.

The rate ceiling, and its refill. 126 sequential GET /api/v1/auth/workspace?name=… requests all returned 200 — which looks like a limiter that is not working and is in fact the refill behaving as designed. PerIpRateLimiter refills continuously rather than resetting on a window boundary, so at 300/hour it hands back 5 tokens a minute; the ~3 minutes those 126 sequential requests took refunded roughly 25 of them.

Outrunning the refill with a burst of 240 at 12 concurrent:

188 200
52 429

and one of those refusals, with headers:

HTTP 429
Retry-After: 12
{"status":"FAIL","message":"Too many requests. Please try again in 12 second(s)."}

Retry-After: 12 is arithmetic confirmation of which ceiling fired: 3_600_000 ms / 300 / 1000 = 12, the time for one token to accrue in the CREDENTIAL bucket. The earlier 126 requests plus this burst crossed 300 in the hour.

SimpleRateLimiter also exists in this codebase and is not used on inbound paths. It is a single global counter that enforces itself with Thread.sleep — correct for pacing outbound AI calls from a worker, and catastrophic on an inbound path, where a flood would park one blocked Tomcat thread per request until the connector pool is gone. The limiter would become the outage. PerIpRateLimiter never blocks, is keyed, refills continuously, and bounds its own map.

That bound matters for the same reason: the key is attacker-influenced on the two IP-keyed groups, so an unbounded map is the denial of service it exists to prevent. Eviction is least-recently-used, capped at max-tracked-keys: 20000, and fail-open — an evicted bucket allows the request rather than refusing it.

No rate-limit environment variable is set on any dev container except ai-service’s LOG_ANALYSIS_PUBLIC_RATE_LIMIT=60 (verified by reading env inside each running container), so the defaults in code are what is running:

serviceinbound limiterdefaultstate on dev
backendPublicRateLimitFilter + AuthRateLimitFilter + attempt ceilingsenabledON — proven by the 429 above
ai-serviceper-JWT-subject sliding window; chat 60/min, global backstop 1200/min, maxKeys 20000RATE_LIMIT_DISABLED=falseON
mcp-servertoken bucket per group — mcp 600/60, skills_package 20/60, installer 60/60default ON (“an opt-in limiter is one nobody opted into”)ON
analyzertoken bucket, allow-list — analyze 15000/60, llm 30/60, read 600/60, write 60/60default ON since 1fe7f3e0; only ANALYZER_RATE_LIMIT_ENABLED=false disablesON
analyzeradmission control (bounded queue → 429 + Retry-After)ANALYZE_MAX_INFLIGHT defaults to 0, and enabled is limit > 0OFF — unset, by design (see below)

Each of these is a property of the code that was running when this page was written, with the place it is enforced.

Startup fails closed on a weak signing secret

Section titled “Startup fails closed on a weak signing secret”

JwtSecretGuard refuses to start on a blank, placeholder, or too-short security.jwt.secret-key when security.jwt.require-strong-secret=true (the prod default). The access, refresh, MFA and email-verification keys all derive from that one value, and it once shipped as a committed placeholder — a token signed with it is forgeable by anyone who has read the repository. dev and test set the flag false and print a warning to stderr instead, which is why SECURITY_JWT_SECRET_KEY must be exported to run the suite locally.

Workspace scope comes from the token, never the request

Section titled “Workspace scope comes from the token, never the request”

The tenant fence reads the workspace from the JWT workspaceId claim only — never a request parameter or body field — via BearerWorkspaceScope.resolve. An absent claim fails closed. There is an off-switch for installations still serving tokens minted before the claim existed; it is a compatibility escape hatch, not the default.

Another tenant’s id is 404, with the same words as a missing row

Section titled “Another tenant’s id is 404, with the same words as a missing row”

OwnershipService.denial() answers NOT_FOUND, and the reason string is the same literal the absent-row branch throws — "project not found", "scan not found", "workspace not found". Matching the status is not enough: a different message re-opens the enumeration oracle in the body. The pre-fix 403 wording is retained verbatim so the off-switch restores the old response exactly rather than approximately.

Roles are ranked, never compared for equality

Section titled “Roles are ranked, never compared for equality”

RoleFloors.require resolves a floor and tests WorkspaceRoles.rank(role) >= required. An unknown role ranks -1 and is therefore refused, not waved through. Equality checks are what previously locked a workspace’s creator out of their own workspace when a higher role was added above theirs.

CORS is an allow-list, mounted where it cannot be skipped

Section titled “CORS is an allow-list, mounted where it cannot be skipped”

CorsConfig holds 12 explicit origins and no origin wildcard. (setAllowedHeaders("*") is present and is a different thing — the origin list is what bounds who may read a credentialed response, and allowCredentials with allowedOriginPatterns("*") is the combination the file explicitly refuses, because it reflects whatever Origin is sent.) It is a single global servlet filter at Ordered.HIGHEST_PRECEDENCE, not a WebMvcConfigurer mapping — because the MVC layer only runs once a request reaches a matched handler, so an auth failure on a permit-all /api/v1/** route produced a response with no CORS headers, which a browser reports as a CORS error rather than the 401 it is.

The catch-all handler returns the exception type plus a generated reference (err-xxxxxxxx) that is logged with the stack, and nothing else. It previously put ex.getMessage() in details, and on a JDBC failure that message is the statement — account deletion returned the table name, the violated foreign-key constraint and the DELETE FROM to the caller.

Any path that gives up before a scan starts must emit a terminal ERROR: <stage>: <reason> frame and throw. A bare return or an NPE into the executor log is a scan that appears to run forever. The corollary — that a refusal has to arrive on the channel the caller is actually reading — is in API Validation.

Deploys fail closed on data they cannot account for

Section titled “Deploys fail closed on data they cannot account for”

ops/deploy/safety.py refuses to recreate a container whose data lives only in its writable layer. It fails closed by construction: an unresolvable path, an unmeasurable layer and an unknown container are all unsafe, and an unmeasurable size is returned as None rather than 0, because 0 means “measured, empty” and treating an unmeasured container as empty is how the guard fails open. A caller that never asked is a distinct NOT_ASKED sentinel, so a legacy call site cannot inherit an “unknown ⇒ unsafe” verdict and start refusing everything.

mvn -B test runs checkstyle through validate, so these fail locally and in CI on the self-hosted logrite-dev runner: FileLength 300 (on src/main/java and src/test/java), MethodLength 80, CyclomaticComplexity 15, ParameterNumber 8. The suppression list holds zero entries and scripts/checkstyle-ratchet.sh fails the build if it holds any — measured on this pass: base=0 current=0.

The one 429 that deliberately has no Retry-After

Section titled “The one 429 that deliberately has no Retry-After”

OtpGuard.registerFailure destroys the one-time code when it reaches the ceiling rather than merely refusing it — a lock that only has to be waited out costs an attacker time and nothing else, so the code is burned and a new one must be sent to the real inbox. There is therefore no interval after which that request succeeds, and a Retry-After would send a client back to an endpoint that can only refuse it again.

This is why the header is attached by exception type and not by status: “every 429 gets a Retry-After” would have produced a header that lies. A header that lies is worse than one that is absent, because a client acts on it.

Worth its own section, because the first attempt at the Retry-After fix passed every test and changed nothing on the one path anybody probes.

Retry-After was added to GlobalApiExceptionHandler. Re-probing the deployed build, the login lock answered exactly as before — 6:429 with no header. The body is what gave it away:

{"message":"Too many failed attempts. Try again in 10 minute(s)...","status":429}

That is a {status, message} map, not the ApiError envelope. So this response never passed through the global advice at all: AuthenticationController has its own @ExceptionHandler(ResponseStatusException.class), and a controller-local handler wins over a @ControllerAdvice. That is correct and deliberate — the auth screens read that shape — and it is exactly why the fix missed.

The warning was already written down in the code the fix added, which is the uncomfortable part: a header set through a mechanism that handler never reads is a fix that tests green and ships nothing. The test asserted on the helper rather than on the envelope the controller returns, so it could not have caught it.

Two controllers held byte-identical copies of that handler. They share one ApiErrors.statusEnvelope now, so a third auth controller copying the pattern gets the header rather than silently not getting it.

Two defects on this page were found by measuring rather than by reading, and both are closed. They are recorded here because a page that reports only the end state hides how it was found.

what was measuredwhat it turned out to befixed in
6 wrong logins → 429 with zero Retry-After headers, while a 240-request burst → 429 with Retry-After: 127 of the backend’s 8 429 sources named a wait only in English prose; one of the 8 correctly has no wait at all40677513
no rate-limit environment variable set on any dev container, and the analyzer’s limiter tested === 'true'the analyzer’s limiter was not merely off on dev — it had never been active anywhere since it was written1fe7f3e0
a 900-request burst with a malformed body (a broken probe: xargs -I{} substituted the sequence number into -d '{}') → 900 × 500the analyzer answered 500 for a client’s own unparseable body, so the backend’s AnalyzerRetry re-sent a request that could never succeed1f7378a3

Verified on the deployed build after each fix:

analyzer, limiter with NO environment variable set
GET /analysis-types 200 x-ratelimit-group: read x-ratelimit-limit: 600
900 x POST /rules/validate 237 x 400, 663 x 429
the refusal 429 retry-after: 1
{"error":"rate_limited","group":"write","limit":60,"retry_after_ms":271}
analyzer, malformed body (was 500)
POST /rules/validate -d '899' 400 {"error":"invalid_request_body", ...}
POST /analyze -d '{"files":' 400
POST /rules/validate -d '{}' 400 {"error":"rule.ruleId is mandatory"} <- unchanged
backend, the rate ceiling
25 x POST /auth/login 429 at request 16 retry-after: 45

retry-after: 45 is 900_000 ms / 20 / 1000 — the credential bucket’s own refill rate, not a constant, which is what proves the header is derived from the bucket that refused.

The second is the one worth generalising. A guard whose activation depends on someone remembering an environment variable is a guard that is off, and the evidence was not subtle: not one container set one. The analyzer had borrowed its default-off stance from a rule about enterprise-scale capabilities, and the distinction it missed is that each of those changes what the service computes — so defaulting one on changes output nobody asked to change — whereas a rate limit changes no output at all. mcp-server’s limiter in this same estate had already reached that conclusion in its own words: an opt-in limiter is one nobody opted into.

Default-on was only safe to do because analyze’s capacity had already been measured against a real scan’s fan-out. At the old 120/60s a 3,600-request fan-out took 2,294 429s — 64% of one scan — so turning the guard on then would have broken the product, which is worse than leaving it off. At 15,000 it is 2x the measured physical per-worker ceiling and a scan cannot reach it.

Scope it honestly: the analyzer surface is token-gated (401 without X-Analyzer-Token), so what was unmetered was an authenticated or leaked-token caller, not the open internet.

Stated because a page that only lists what works is not an accurate page.

  • No load balancer and no horizontal scaling. One container per service, so a restart is a full outage for that service rather than a drained instance. No duration is claimed: the start of the window was never timed, only the recovery (first 302 at 12:54:43 UTC). An earlier version of this page said “~90 seconds”; that figure was never measured and has been removed.
  • The analyzer’s admission control is still off, and that one is deliberate. ANALYZE_MAX_INFLIGHT is named in the analyzer’s own standing rule — “enterprise-scale capabilities are opt-in and default-off, so an unchanged deployment runs the path the golden corpus covers” — so it stays a config change rather than a code change. The analyzer’s rate limiter was in the same state and was not covered by that rule, and is fixed: see What the measurement changed.
  • Cloudflare-level WAF or rate rules are unverified — not readable from here.
  • Prod topology is unverified — no SSH access during this pass.