mirror of
https://github.com/coturn/coturn.git
synced 2026-10-24 00:08:57 +03:00
TURN/STUN long-term auth answers the first unauthenticated request with
a 401 Unauthorized carrying REALM/NONCE. On UDP, where source addresses
are spoofable and the 401 is larger than the request that triggers it,
the server becomes a reflection/amplification vector: an attacker forges
a victim's source IP and bounces amplified 401s at it.
This adds an opt-in mitigation that caps how many UDP 401 responses the
server emits toward any single source IP per second. Past the cap the
server stays silent, denying both the reflection and the amplification.
Authenticated relay traffic is untouched — only requests that reach the
401 branch spend budget.
Flags (off by default):
--unauthorized-ratelimit Enable the mitigation (UDP only).
--unauthorized-ratelimit-rps=<n> Max 401 responses per source IP per
second. Default 10.
Implementation:
- Lock-free, fixed 4096-bucket direct-mapped table (~80 KiB, no
malloc/free, no lock, no list scan on the hot path); the source IP
appears only as a 32-bit tag, never as a pointer. The port is stripped
from the key so an attacker cannot evade the limit by rotating the
source port. A fixed 1-second window backs the per-second limit.
- Colliding addresses share the live bucket budget instead of receiving
a fresh allowance, so hash collisions can never expand reflected output.
- Drop and collision diagnostics are each latched to one log line per
(bucket, window) to avoid log amplification.
- Portable 32-bit atomics over C11 <stdatomic.h> with an MSVC
Interlocked* fallback; the on/off flag and the per-second threshold are
pointers into turn_params so every relay thread sees live CLI values
without per-thread copies.
Observability (Prometheus):
- turn_unauthenticated_401_{requests,responses,dropped_responses}
- turn_ratelimit_{hash_collisions,occupied_buckets,total_buckets}
Tests & docs:
- Unity unit test (tests/test_ratelimit.c) covering live-bucket
collisions, occupancy tracking, and the collision counter.
- End-to-end positive/negative system test (run_tests_ratelimit_401.sh)
and a Prometheus counter test (run_tests_prom.sh).
- A 401-response-flood load harness under examples/loadtest/.
- docs/401-ratelimit.md design note, plus man page, README, Prometheus
docs, and the example/Docker turnserver.conf entries.
116 lines
4.2 KiB
C
116 lines
4.2 KiB
C
#include "ns_turn_ratelimit.h"
|
|
|
|
#include <unity.h>
|
|
|
|
#include <stdio.h>
|
|
|
|
void setUp(void) { ratelimit_init(); }
|
|
void tearDown(void) {}
|
|
|
|
static void test_live_bucket_collision_shares_exhausted_budget(void) {
|
|
ioa_addr victim = {0};
|
|
ioa_addr collider = {0};
|
|
bool found_collision = false;
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, make_ioa_addr((const uint8_t *)"203.0.113.9", 3478, &victim));
|
|
|
|
for (unsigned int i = 0; i < 65536 && !found_collision; ++i) {
|
|
char candidate[INET_ADDRSTRLEN] = {0};
|
|
bool first_drop = false;
|
|
bool first_collision = false;
|
|
|
|
snprintf(candidate, sizeof(candidate), "198.18.%u.%u", i >> 8, i & 0xffu);
|
|
TEST_ASSERT_EQUAL_INT(0, make_ioa_addr((const uint8_t *)candidate, 3478, &collider));
|
|
|
|
ratelimit_init();
|
|
TEST_ASSERT_FALSE(ratelimit_consume_address(&victim, 1u, NULL, NULL));
|
|
found_collision =
|
|
ratelimit_consume_address(&collider, 1u, &first_drop, &first_collision) && first_drop && first_collision;
|
|
}
|
|
|
|
TEST_ASSERT_TRUE_MESSAGE(found_collision, "failed to locate a bucket collision for regression coverage");
|
|
|
|
ratelimit_init();
|
|
TEST_ASSERT_FALSE(ratelimit_consume_address(&victim, 1u, NULL, NULL));
|
|
|
|
bool first_drop = false;
|
|
bool first_collision = false;
|
|
TEST_ASSERT_TRUE(ratelimit_consume_address(&collider, 1u, &first_drop, &first_collision));
|
|
TEST_ASSERT_TRUE(first_drop);
|
|
TEST_ASSERT_TRUE(first_collision);
|
|
|
|
first_drop = false;
|
|
first_collision = false;
|
|
TEST_ASSERT_TRUE(ratelimit_consume_address(&collider, 1u, &first_drop, &first_collision));
|
|
TEST_ASSERT_FALSE(first_drop);
|
|
TEST_ASSERT_FALSE(first_collision);
|
|
|
|
first_drop = false;
|
|
first_collision = false;
|
|
TEST_ASSERT_TRUE(ratelimit_consume_address(&victim, 1u, &first_drop, &first_collision));
|
|
TEST_ASSERT_FALSE(first_drop);
|
|
TEST_ASSERT_FALSE(first_collision);
|
|
}
|
|
|
|
static void test_capacity_is_nonzero_power_of_two(void) {
|
|
uint32_t cap = ratelimit_get_capacity();
|
|
TEST_ASSERT_GREATER_THAN_UINT32(0u, cap);
|
|
TEST_ASSERT_EQUAL_UINT32(0u, cap & (cap - 1u));
|
|
}
|
|
|
|
static void test_occupancy_tracks_live_buckets(void) {
|
|
ioa_addr a = {0};
|
|
TEST_ASSERT_EQUAL_INT(0, make_ioa_addr((const uint8_t *)"203.0.113.9", 3478, &a));
|
|
|
|
ratelimit_init();
|
|
TEST_ASSERT_EQUAL_UINT32(0u, ratelimit_count_occupied());
|
|
|
|
/* The first consume opens a window in exactly one bucket. */
|
|
TEST_ASSERT_FALSE(ratelimit_consume_address(&a, 5u, NULL, NULL));
|
|
TEST_ASSERT_EQUAL_UINT32(1u, ratelimit_count_occupied());
|
|
}
|
|
|
|
static void test_collision_counter_counts_every_collision(void) {
|
|
ioa_addr victim = {0};
|
|
ioa_addr collider = {0};
|
|
bool found_collision = false;
|
|
|
|
TEST_ASSERT_EQUAL_INT(0, make_ioa_addr((const uint8_t *)"203.0.113.9", 3478, &victim));
|
|
|
|
/* Locate an address that hashes into the victim's bucket. */
|
|
for (unsigned int i = 0; i < 65536 && !found_collision; ++i) {
|
|
char candidate[INET_ADDRSTRLEN] = {0};
|
|
bool first_collision = false;
|
|
|
|
snprintf(candidate, sizeof(candidate), "198.18.%u.%u", i >> 8, i & 0xffu);
|
|
TEST_ASSERT_EQUAL_INT(0, make_ioa_addr((const uint8_t *)candidate, 3478, &collider));
|
|
|
|
ratelimit_init();
|
|
TEST_ASSERT_FALSE(ratelimit_consume_address(&victim, 1u, NULL, NULL));
|
|
found_collision = ratelimit_consume_address(&collider, 1u, NULL, &first_collision) && first_collision;
|
|
}
|
|
TEST_ASSERT_TRUE_MESSAGE(found_collision, "failed to locate a bucket collision for counter coverage");
|
|
|
|
ratelimit_init();
|
|
TEST_ASSERT_EQUAL_UINT32(0u, ratelimit_get_collisions());
|
|
|
|
/* The bucket owner's own request is never a collision. */
|
|
TEST_ASSERT_FALSE(ratelimit_consume_address(&victim, 1u, NULL, NULL));
|
|
TEST_ASSERT_EQUAL_UINT32(0u, ratelimit_get_collisions());
|
|
|
|
/* Every colliding request is counted, not just the first one in the window. */
|
|
ratelimit_consume_address(&collider, 1u, NULL, NULL);
|
|
TEST_ASSERT_EQUAL_UINT32(1u, ratelimit_get_collisions());
|
|
ratelimit_consume_address(&collider, 1u, NULL, NULL);
|
|
TEST_ASSERT_EQUAL_UINT32(2u, ratelimit_get_collisions());
|
|
}
|
|
|
|
int main(void) {
|
|
UNITY_BEGIN();
|
|
RUN_TEST(test_live_bucket_collision_shares_exhausted_budget);
|
|
RUN_TEST(test_capacity_is_nonzero_power_of_two);
|
|
RUN_TEST(test_occupancy_tracks_live_buckets);
|
|
RUN_TEST(test_collision_counter_counts_every_collision);
|
|
return UNITY_END();
|
|
}
|