Add abuse_shield plugin - #13586
Conversation
8a85398 to
09791c5
Compare
09791c5 to
557027f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 45 out of 45 changed files in this pull request and generated 5 comments.
Suppressed comments (3)
tests/gold_tests/pluginTest/abuse_shield/h2_rate_client.py:135
- receive_responses() documents a
timeoutparameter and callers pass non-default values, but the implementation never uses it. This makes the end-of-run wait loop effectively busy-poll and can introduce flakiness on slow CI (responses may arrive after the immediate non-blocking recv loop exits).
include/tsutil/UdiTable.h:368 - seconds_since_reset() reads last_reset_time_ without holding mutex_, which can race with reset_metrics(). Copy the timestamp under the mutex (or lock for the whole function) to preserve the class’ thread-safety guarantee.
uint64_t
UdiTable<Key, Data, Hash>::seconds_since_reset() const
{
auto now = std::chrono::system_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(now - last_reset_time_);
plugins/experimental/abuse_shield/abuse_shield.cc:109
- current_time_str() uses std::localtime(), which is not thread-safe and can produce corrupted output when called concurrently from multiple ATS threads. Use localtime_r()/ink_localtime_r() with a stack tm instead.
auto now = std::chrono::system_clock::now();
auto now_t = std::chrono::system_clock::to_time_t(now);
std::ostringstream oss;
oss << std::put_time(std::localtime(&now_t), "%Y-%m-%dT%H:%M:%S");
return oss.str();
557027f to
03d1954
Compare
03d1954 to
2f9f29d
Compare
bryancall
left a comment
There was a problem hiding this comment.
Thanks for this. It is a substantial piece of work and a lot of it is done well: the reload path (validate into a new config, then swap under the lock) is correct, every hook path re-enables exactly once, the fingerprint provider registry is clean, and the fingerprint gold test asserts real enforcement end to end. I went through the whole change and I think there is a set of correctness problems that need to be resolved before this merges. The first three are about what the plugin actually enforces.
Blocking issues
1. Per-rule rate thresholds are not enforced.
Config::rate_limit_for() sizes the single shared per-IP bucket with the minimum rate across all applicable rules (config.cc:434-455). The per-rule checks then use the rule's own rate only as an on/off gate and test the shared bucket's sign (abuse_shield.cc:186-196). With two rules on one metric, for example a block rule at 500 requests/second listed before a log-only rule at 50, the bucket drains at 50 and the block rule fires at 51 requests/second, ten times below its configured threshold. Reverse the order and the block rule can never fire at any rate. filter_uses_metric() also does not exclude fingerprint rules, so a fingerprint rule that sets max_req_rate shrinks the bucket used by every other rule. The multiple-rules gold test only covers the strict-rule-first ordering, which is the one ordering that happens to look correct.
2. TS_VCONN_START_HOOK never fires for plain HTTP.
The comment at abuse_shield.cc:1112 says this is the earliest hook for both HTTP and HTTPS, but TS_EVENT_VCONN_START is only dispatched from TLSEventSupport (TLSEventSupport.cc:457, 489), which is a service of SSL vconns only; the SSL hooks documentation says the same. On a plain HTTP listener, connection rates are never tracked, the blocked-IP rejection at abuse_shield.cc:690-702 never runs, and abuse_shield.connections.rejected is always zero even though stats.h documents it as covering HTTP and HTTPS. The connection-rate gold tests only use the SSL port, so CI cannot catch this. Either also register TS_HTTP_SSN_START_HOOK for connection accounting and blocking, or document the connection-level features as TLS only.
3. Connection-rate rules are never evaluated at connection time.
handle_vconn_start consumes a connection token but never calls evaluate_rate_rules(); rules are evaluated only at transaction close (abuse_shield.cc:791) and on an HTTP/2 error (572). A client that opens connections at a high rate but never completes a request (connect and idle, or an aborted handshake) drives the bucket deeply negative and no action ever fires: no log, no block, no close. The gold tests use curl, which always completes a transaction, so this is invisible there as well.
4. consume_token() is not atomic.
ip_data.cc:27-55 is a load, compute, store sequence over two relaxed atomics with no compare-and-swap. Per-IP data is shared across event threads (the tables are keyed by IP and connections from one IP are distributed round-robin), so concurrent consumers lose decrements and double-apply replenishment; the limiter under-counts in proportion to how parallel the client is, which is the wrong direction for an abuse-protection plugin. Separately, now is read before last, so an interleaving where another thread stores a newer last_update in between makes elapsed_ms = now - last wrap to roughly 2^64, and the truncation to int32_t produces either a full bucket reset for the abuser or a deeply negative value that gets an innocent client blocked. A compare-and-swap loop over a packed 64-bit word (tokens plus timestamp) fixes both, along with a now <= last guard. There is currently no multi-threaded test for this function.
5. Block state lives in the evictable table it is meant to protect.
block_ip() only writes into slots found by find() and silently does nothing when the IP has no slot (abuse_shield.cc:329-349), which is exactly the table-saturation case a large attack creates. When a slot is evicted and the key later returns, contest() installs a fresh Data with blocked_until of zero (UdiTable.h:425), so the block evaporates before duration_seconds; a quiet blocked IP decays deterministically via the contest pointer sweep, and an attacker can accelerate its own eviction by cycling source addresses. Because the token debt lives in the same slot, eviction erases the debt too. Block and debt state need to survive eviction, either in a separate expiring structure or by refusing to evict blocked slots.
6. Action metrics count intent, not effect.
actions_blocked (abuse_shield.cc:471), actions_closed (480) and connections_rejected (692) are all incremented before the operation that can fail: block_ip can be a no-op, and the fd < 0 path silently skips the shutdown with no error, after which handle_vconn_start re-enables the vconn so the "rejected" connection proceeds normally. During an attack an operator can watch abuse_shield.actions.blocked climb while nothing is actually blocked. The increments should happen after the operation reports success, with a distinct failure counter and a TSError on the failure path.
7. Config parsing fails open on operator typos.
The enforcement keys use as<T>(fallback), which returns the fallback on conversion failure, not just on a missing key: slots (config.cc:257), duration_seconds (263), and all the max_*_rate keys (294-299). max_req_rate: 1OO (letter O) silently becomes zero, which means disabled, and the rule loads with no diagnostic. Meanwhile log_interval_sec uses bare as<int>() and fails the load, so the least security-critical key is the only one that fails closed. In the same cluster: a scalar action: block (instead of a sequence) yields a rule with zero actions and no warning (config.cc:41-51); downgrade is parsed (63-64) and printed but handled nowhere in execute_actions and is absent from the documentation; slots: 0 passes validation and produces three permanently empty tables, at which point the "should never happen" guard at UdiTable.h:402-405 runs on every event; negative duration_seconds and log_interval_sec silently neutralize the block and log actions. validate() should cover all of these, and unknown action strings should fail the load.
Other issues
- Token debt policy is implicit and the arithmetic can wrap. There is no floor on
tokens, so a large burst leaves a debt that takes far longer thanduration_secondsto pay off. I am fine with proportional punishment as a policy, and I would not add a floor that lets an abuser wait out a fixed window and immediately start again. But the policy should be stated in the documentation, sinceduration_secondscurrently understates the real penalty, and the decrement must saturate:tokensisint32_t, and about 2.1 billion net consumes wraps the debt into a large positive value that the next replenish clamps to full burst, so a sufficiently heavy attack resets its own bucket. - Hot-path lock contention.
sync_all_tracker_stats()runs on every VCONN_START, TXN_START and TXN_CLOSE (abuse_shield.cc:715, 781, 797) and takes all three global table mutexes each time; together with the per-request lookups, a matching transaction acquires the three process-wide mutexes on the order of ten times. A periodic scheduled sync would do; the.statsand.dumpmessages already sync explicitly. Relatedly,Config::partitions_is never parsed from YAML and never read, and should be removed or wired up. dump()formats under the table mutex (UdiTable.h:374-392); with 50,000 slots,traffic_ctl plugin msg abuse_shield.dumpstalls every event thread touching that table for the whole formatting pass. Snapshot under the lock and format outside. A callback that calls back into the table also self-deadlocks on the non-recursive mutex, which is worth documenting.- Reload reports success while silently ignoring or reverting settings.
ip_tracking.slotsandlog_fileare consumed only at plugin init, so changing them and reloading does nothing while logging success. A runtimeabuse_shield.enabled 0is silently reverted by the next reload, sinceparse()re-readsenabledfrom the file with a default of true (config.cc:359). An operator who disables the plugin during an incident gets it re-enabled by an unrelated rules push. The reload handler should log which settings were detected as changed but not applicable. - The jax_fingerprint refactor drops three debug statements: the JA3 pre-hash trace and both "using legacy version" warnings for a malformed or absent supported_versions extension. Those were the only way to see why a computed fingerprint differs from an expected one, and a malformed ClientHello now silently changes the JA4, which in this plugin decides whether the connection is dropped. The shared algorithm files should get their own
DbgCtlrather than losing the messages. - Missing includes:
<algorithm>forstd::max,<cstring>forstrlen,<sys/socket.h>forshutdown. These resolve transitively on Linux today and are my best guess for the FreeBSD build failure on this PR. - No exception barrier in the hook handlers; all of them allocate, so a
bad_allocpropagates into the event loop.Config::parsecatches onlyYAML::Exception. - Use after move at config.cc:354-355: the rule is moved into
rules_and thenrule.nameis logged, so the "Loaded rule" debug line prints an empty name. - Inconsistent startup failure handling: registration failure and a missing config argument are
TSErrorplus return (abuse_shield.cc:1055-1064), leaving the proxy running unprotected, while a malformed config file isTSFatal(1076). All three should be fatal for a security plugin. - The
read()drain loop aftershutdown(fd, SHUT_RDWR)is dead code, since the read side is already shut down; it appears twice and can go. The HTTP/2 error code is truncated touint8_tat abuse_shield.cc:569, so code 256 is counted as NO_ERROR in the dump.
UdiTable as an installed header
UdiTable.h is in TSUTIL_PUBLIC_HEADERS, so it ships as installed API with one experimental consumer. Two load-bearing contracts are unstated: Data must be internally thread-safe (the table mutex does not cover it), and eviction detaches a returned handle, after which writes are lost and a returning key gets a fresh Data, with no way for a holder to detect that. The second one is the root cause of blocking issue 5. Smaller items: Hash is templated but KeyEqual is not; num_slots == 0 is accepted silently; slot.score += delta on uint32_t does not saturate, so a long-lived hot key wraps and becomes instantly evictable; the dump() callback receives a mutable Data from a const method. I would keep the header local to the plugin until the contract hardens and a second consumer exists. Separately, quoting US Patent 7,533,414 verbatim in an installed ASF header seems like something that should get explicit legal sign-off on the record.
Test coverage
- None of the block tests assert enforcement. All six assert the diags line emitted by the log action, which fires independently of block and close; removing
block_ip()and the vconn-start enforcement block leaves every one of them green.connections.rejected,actions.blockedandactions.closedare never queried. The block-expiration test floods vialocalhostbut verifies via127.0.0.1, which are different tracker keys when localhost resolves to ::1, and it never asserts a failure while the block is active. The fingerprint test is the model here; the rate tests should follow it. - The HTTP/2 error tracker has no integration coverage; no test client ever sends RST_STREAM, and the
max_h2_error_raterule in the message test receives no traffic. - Trusted-IP bypass is never exercised: the trusted YAML file is written but no test config ever sets
global.trusted_ips_file. An invertedis_trustedcheck would block health checkers and pass every existing test. - The reload-rejected path (invalid config keeps the old one) is untested, as are the
.reset,.statsand.trustedmessages. - The test file should be added to
tests/serial_tests.txt; it starts ten Traffic Server instances and the tightest rate test needs 250 requests to complete within about 1.5 seconds, which will flake under AddressSanitizer with parallel test load. Theseq | xargs ... || truepattern makes theReturnCodeassertions vacuous, andh2_rate_client.pyexits zero when streams receive no response at all, since unanswered streams are excluded from the failure count. - Three gold cases would have caught the blocking issues above: a multi-rule config with the lenient rule listed first, a connection-rate rule on the plain HTTP port, and a connect-and-idle client.
Happy to go over any of this. The fingerprint work and the overall structure are in good shape; the enforcement path is what needs another pass.
2f9f29d to
b41be77
Compare
|
Thanks for the thorough review. I addressed the observations in I also removed hot-path table-stat synchronization, moved dump formatting outside the table lock, restored JAx diagnostics, added exception barriers/direct includes, and corrected the H2 error handling. The AuTest coverage now exercises actual block enforcement, H2 RST_STREAM errors, trusted bypass, rejected reload retention, plain HTTP connection limiting, connect-and-idle TLS clients, lenient-first rules, and serial execution. Normal, ASan/ODR, AuTest, formatting, and documentation builds pass. |
bryancall
left a comment
There was a problem hiding this comment.
Thanks, this is a substantial rework and most of it lands cleanly. The branch was squashed into a single commit, so instead of reading a "changes since" diff I re-checked each earlier item against the new head at b41be77d.
Resolved, with the evidence I looked at:
- Per-rule thresholds.
RuleBucketsgives every rule its own bucket keyed by rule name, sized from that rule's own rate (ip_data.h:56-73); the min-across-rules sizing andfilter_uses_metricgating are gone, andvalidate()now rejects duplicate and empty rule names so the key cannot collide. The unit test at test_ip_data.cc:62-76 and the reordered gold case both pin the behavior. - Plain HTTP.
TS_HTTP_SSN_START_HOOKis registered and skips sessions whose protocol stack containstls/, so each connection is counted exactly once. The plain HTTP gold case now uses amax_conn_raterule and assertsxargsexit 123, so it fails if nothing is actually rejected. - Connection-time evaluation. Both connection hooks consume and then call
evaluate_rate_rulesimmediately, andidle_connections.pycovers the connect-and-idle client. - Token bucket atomicity. The compare-and-swap removes the lost decrements and the
std::maxat ip_data.cc:75 removes the wrap. One residual, noted below, but not a merge blocker. - Block state.
BlockedIpTableis a separate bounded map that never drops an unexpired block, and debt-holding entries are protected from eviction. The protection itself is right; see the blocking note for how it is implemented. - Action metrics. Every increment now follows the operation, with
actions.block_failed,actions.close_failedandconnections.reject_failedand aTSErroron each failure path. - Config. Unknown actions, non-sequence
action,slots: 0, non-positiveduration_seconds, negative rates, burst multipliers below 1.0 or overflowing INT32, and rules with no criteria all fail the load now.downgradeis gone.
The rest of my earlier list is addressed as well: hot-path stat syncing removed, dump() snapshots under the lock and formats outside it, reload preserves the runtime enabled state and rejects startup-only changes, the JAx debug statements are back, the direct includes and exception barriers are in, the use-after-move logging is fixed, all three startup failures are fatal, the dead read() drain is gone, the HTTP/2 error code is no longer truncated, partitions_ is removed, and UdiTable.h is private to the plugin with the patent text dropped.
Blocking
TSError fires on the ordinary contest-loss path, not on debt exhaustion.
abuse_shield.cc:259-265 treats a null return from process_event() as a saturation failure:
slot = tracker->process_event(ip, 1);
if (!slot) {
TSError("[%s] Tracking table is full of protected rate-debt entries; event for %s was not tracked", ...);
return;
}
But null is the normal, common outcome of a contest and always has been. contest() probes a slot, and a newcomer arriving with score 1 against a hotter incumbent loses and returns null after decrementing the incumbent, which is the whole point of the King of the Hill design. That branch is unchanged from the previous revision. On any proxy seeing more distinct client addresses than there are slots, a large share of new arrivals lose their first contest, so this logs at error severity, with a client IP in the message, on a steady fraction of ordinary traffic. No attack required.
To size it I simulated the contest mechanic against a warm 50,000-slot table with no debt anywhere, varying the client-address distribution:
2,000,000 clients, flat | 2.5% of requests hit a tracked IP | 50.0% lose a contest
500,000 clients, zipf 0.8 | 52.6% | 33.6%
2,000,000 clients, zipf 1.0 | 70.2% | 20.3%
2,000,000 clients, zipf 1.2 | 92.6% | 5.1%
Even in the most skewed case, where 92.6% of requests come from addresses already in the table, one request in twenty loses a contest. At 100,000 requests per second that is roughly 5,000 error lines per second, and with a flatter address mix it approaches 50,000. That is a simulation of the mechanic rather than instrumented Traffic Server, so treat the exact percentages as illustrative, but the shape follows directly from having more client addresses than slots, which is the premise the table is built on.
Two problems: the severity, and the message. An expected contest loss is not an error, and the text attributes it to debt protection when the overwhelmingly common cause is simply losing on score. A counter is the right instrument here. If a distinct signal for genuine debt saturation is wanted, contest() needs to distinguish the two null paths rather than have the caller guess.
Non-blocking
-
The debt-protection scan is a cliff at total saturation, not a slope.
contest()at UdiTable.h:405-418 walks for an evictable slot, holding the global table mutex, taking each probed entry's bucket mutex. I measured the walk length against debt density at 50,000 slots, and it stays cheap until the table is completely full of debt:debt 90% | 9.8 probes | 0.10 us debt 99% | 101.2 probes | 0.65 us debt 100% | 50000.0 probes | 229.80 usAt 99%, meaning 49,500 tracked addresses simultaneously over their limits, it is still sub-microsecond. Only at literally every slot in debt does it degrade to a full rotation, and being serialized on one mutex that puts a process-wide ceiling near 4,000 events per second. I do not think that is a realistic steady state, so I am not blocking on it, but the unbounded loop is worth a cap since the degradation at the edge is severe and the bound costs nothing. Note this scan is downstream of my own earlier comment asking for debt to survive eviction; the previous revision probed exactly one slot and was O(1). "Protect debt from eviction" and "bounded work per event" conflict when every entry has debt, and which way to resolve that is your call rather than mine.
-
Residual in the token bucket.
nowis read once before the CAS loop at ip_data.cc:55 and not refreshed on retry, so a thread that loses a CAS to one that read a later millisecond computes a negative delta, the unsigned subtraction at ip_data.cc:64 wraps, and the bucket refills toburst_limit. I want to be clear about the scale before anyone spends time on it: I only reproduced this with threads consuming the same bucket in a tight loop with no spacing. Adding even 10 microseconds between events, still 392,000 requests per second from one address, drove the error to zero. It is not reachable through real traffic. Worth the two-line fix, since moving the clock read inside the loop and clamping a negative delta to zero is free, but not something to hold the merge for. Note that the new threaded test at test_ip_data.cc:39-60 passesrate_per_sec = 0, which takes the replenish branch out of play entirely, so it would not catch a regression here either way. -
The block-expiration test still floods with
--host localhostand then verifies withhttps://127.0.0.1(abuse_shield.test.py:907-938). Those are different tracker keys when localhost resolves to::1, and the test never asserts a failure while the block is active, so the step 3 assertion can pass without a block ever having been enforced. Using one address for both halves and adding a negative assertion in between would close it. -
No test asserts that
abuse_shield.actions.blockedorabuse_shield.connections.rejectedis non-zero. The trusted-bypass case queriesactions.blockedonly to assert it is zero. One positive metric assertion on an enforcing case would cover the counters the operator actually watches. -
RuleBuckets::findandfind_or_createbuild astd::stringfrom the rule name on every call, so a matching request heap-allocates once per rule per metric. Heterogeneous lookup would avoid it. -
In tls_client_hello_summary.cc:30-35 the new anonymous namespace sits above the
#include <openssl/sha.h>block. It compiles, but the includes should come first.
The enforcement rework is solid, and items 1 through 7 are genuinely resolved. The error-severity logging on the normal path is the one thing I would want changed before this goes in.
Traffic Server lacks a unified, bounded-memory control for abusive clients. Operators otherwise need separate mechanisms to track request, connection, and HTTP/2 error rates, exempt trusted networks, and act on clients that exceed policy. This patch adds the experimental abuse_shield global plugin with per-IP token-bucket rate limits, trusted and tiered IP policies, logging, temporary blocking, connection closing, live configuration reloads, and metrics. Its bounded tracking tables cap memory use under high-cardinality traffic. The plugin can also match JA3 and JA4 ClientHello fingerprints before ServerHello. This reuses the read-only JAx calculators and adds a build-time provider registry for downstream fingerprint methods, with documentation and test coverage.
b41be77 to
d4c1217
Compare
|
Thanks for the detailed follow-up. Addressed all of these observations in d4c1217:
Validation completed successfully with the normal focused build/tests, ASan focused build/tests (including ODR detection), the full abuse_shield AuTest, formatting, and the Sphinx documentation build. |
Publish computed fingerprints through a versioned named user-arg registry so abuse_shield can enforce fingerprint rules without recalculating them. Keep method names opaque so downstream JAx implementations can participate.
Review observations identified unclear integration requirements, small contract gaps, and an unsupported heterogeneous map lookup on older libstdc++ versions. This patch initializes the complete fingerprint registry header, documents the table requirements and plugin ordering, aligns test helpers and includes, and clarifies reload behavior. It also uses stored string rule names directly so GCC 10 builds retain allocation-free lookups.
bd810d6 to
a67c098
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 44 out of 44 changed files in this pull request and generated no new comments.
Suppressed comments (13)
Previously missed (3) — in code that hasn't changed since the last review.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:22
- The ASF license block should appear before imports (most other AuTest files place the license header immediately before imports). Having imports before the license can confuse tooling that expects the header near the top of the file.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:350 - This TestRun starts the origin server, but it is not included in StillRunningAfter. AuTest will stop processes that are not kept alive, so subsequent TestRuns that rely on the origin can fail intermittently or consistently.
This issue also appears in the following locations of the same file:
- line 370
- line 591
- line 602
- line 918
- line 932
- ...and 4 more
plugins/experimental/abuse_shield/abuse_shield.cc:502
- The log format uses
IP=%s:%swhile the fingerprint field already includes leading punctuation/spacing. When no fingerprint matches, this produces a trailing ':' in logs. PreferIP=%s%sso the fingerprint suffix is included only when present.
This issue also appears on line 503 of the same file.
if (g_log_object) {
TSTextLogObjectWrite(g_log_object, "Rule \"%s\" matched for IP=%s:%s actions=[%s] req_tokens=%d conn_tokens=%d h2_tokens=%d",
match.rule->name.c_str(), ip_to_string(ip).c_str(), fingerprint.c_str(),
abuse_shield::actions_to_string(match.actions).c_str(), req_tokens, conn_tokens, h2_tokens);
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:370
- This TestRun depends on the origin started in the prior run, but it is not listed in StillRunningAfter, so the harness may have already torn it down at the end of the previous run.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:593 - This TestRun starts the origin server but does not keep it alive via StillRunningAfter, which can break the subsequent TestRun in this test that assumes the origin is still running.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:602 - This second TestRun relies on the origin from the previous run but does not include it in StillRunningAfter, so the harness may stop it before this run executes.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:920 - This TestRun starts the origin server but does not keep it alive via StillRunningAfter; later steps in this multi-step test assume the origin remains available for follow-up curl requests.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:932 - This step should also keep the origin server running (StillRunningAfter) so it is not torn down before the remaining steps in this multi-run scenario complete.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:940 - This step should keep the origin server running (StillRunningAfter) to avoid it being stopped before subsequent TestRuns that still need an origin behind ATS.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:948 - This step should keep the origin server running (StillRunningAfter) for the remainder of the test sequence; otherwise later requests may fail unexpectedly if the origin is stopped.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:954 - This step should keep the origin server running (StillRunningAfter) so it remains available for the final verification request after the sleep.
tests/gold_tests/pluginTest/abuse_shield/abuse_shield.test.py:961 - This final verification request still depends on the origin server configured for the remap. Keep the origin running via StillRunningAfter to avoid flakiness if the harness stops it between runs.
plugins/experimental/abuse_shield/abuse_shield.cc:506 - Same as above:
IP=%s:%syields a trailing ':' when fingerprint is empty. UseIP=%s%sto make the output consistent.
} else {
TSError("[%s] Rule \"%s\" matched for IP=%s:%s actions=[%s] req_tokens=%d conn_tokens=%d h2_tokens=%d", PLUGIN_NAME,
match.rule->name.c_str(), ip_to_string(ip).c_str(), fingerprint.c_str(),
abuse_shield::actions_to_string(match.actions).c_str(), req_tokens, conn_tokens, h2_tokens);
Traffic Server lacks a unified, bounded-memory control for abusive
clients. Operators otherwise need separate mechanisms to track request,
connection, and HTTP/2 error rates, exempt trusted networks, and act on
clients that exceed policy.
This patch adds the experimental abuse_shield global plugin with per-IP
token-bucket rate limits, trusted and tiered IP policies, logging,
temporary blocking, connection closing, live configuration reloads, and
metrics. Its bounded tracking tables cap memory use under
high-cardinality traffic.
The plugin can also match JA3 and JA4 ClientHello fingerprints before
ServerHello. This reuses the read-only JAx calculators and adds a
build-time provider registry for downstream fingerprint methods, with
documentation and test coverage.