From f8c12257272df480110f8ee54dca6c47f256f72d Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Thu, 9 Jul 2026 12:09:24 +0000 Subject: [PATCH 1/3] test(auth): cover token cap eviction and malformed pairing codes Pin auth_pair's 16-token store cap (oldest eviction), reject non-6-digit codes, and reject length-mismatched bearer tokens to reduce auth DoS and bypass regression risk. --- tests/test_auth.c | 156 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/tests/test_auth.c b/tests/test_auth.c index a56fa36..e538ddc 100644 --- a/tests/test_auth.c +++ b/tests/test_auth.c @@ -5,6 +5,7 @@ #define _POSIX_C_SOURCE 200809L #include "gateway/auth.h" +#include "cJSON.h" #include #include #include @@ -13,6 +14,62 @@ #define ASSERT(c) do { if (!(c)) { fprintf(stderr, "FAIL: %s:%d %s\n", __FILE__, __LINE__, #c); return 1; } } while (0) #define RUN(t) do { int r = (t); if (r) return r; } while (0) +/* Matches TOKEN_LEN / auth_pair cap in src/gateway/auth.c. */ +#define TEST_TOKEN_HEX_LEN 32 +#define TEST_AUTH_TOKEN_CAP 16 + +static void format_dummy_token(char *out, size_t out_size, int index) +{ + /* Deterministic 32-char hex so eviction of index 0 is observable. */ + snprintf(out, out_size, "%032d", index); +} + +static int write_token_array(const char *path, int count) +{ + FILE *f; + int i; + char token[TEST_TOKEN_HEX_LEN + 1]; + + f = fopen(path, "w"); + if (!f) + return -1; + fputc('[', f); + for (i = 0; i < count; i++) { + format_dummy_token(token, sizeof(token), i); + if (i > 0) + fputc(',', f); + fprintf(f, "\"%s\"", token); + } + fputc(']', f); + fclose(f); + return 0; +} + +static int read_token_array_size(const char *path) +{ + FILE *f; + char buf[8192]; + size_t n; + cJSON *root; + int size; + + f = fopen(path, "r"); + if (!f) + return -1; + n = fread(buf, 1, sizeof(buf) - 1, f); + fclose(f); + buf[n] = '\0'; + root = cJSON_Parse(buf); + if (!root || !cJSON_IsArray(root)) { + if (root) + cJSON_Delete(root); + return -1; + } + size = cJSON_GetArraySize(root); + cJSON_Delete(root); + return size; +} + static int test_auth_init_cleanup(void) { auth_ctx_t *ctx = auth_init(NULL); @@ -226,6 +283,102 @@ static int test_pair_lockout_null_ip(void) return 0; } +static int test_auth_pair_rejects_malformed_code(void) +{ + const char *path = "/tmp/shellclaw_test_tokens_malformed.json"; + auth_ctx_t *ctx; + char *code; + char token[64]; + + unlink(path); + ctx = auth_init(path); + ASSERT(ctx != NULL); + code = auth_get_or_create_pairing_code(ctx); + ASSERT(code != NULL); + + memset(token, 0, sizeof(token)); + ASSERT(auth_pair(ctx, "12345", token, sizeof(token)) != 0); + ASSERT(auth_pair(ctx, "1234567", token, sizeof(token)) != 0); + ASSERT(auth_pair(ctx, "12a456", token, sizeof(token)) != 0); + ASSERT(auth_pair(ctx, "", token, sizeof(token)) != 0); + ASSERT(token[0] == '\0'); + + /* Valid pending code still pairs after malformed rejects. */ + ASSERT(auth_pair(ctx, code, token, sizeof(token)) == 0); + ASSERT(strlen(token) == TEST_TOKEN_HEX_LEN); + ASSERT(auth_validate_token(ctx, token) == 1); + + free(code); + auth_cleanup(ctx); + unlink(path); + return 0; +} + +static int test_auth_pair_evicts_oldest_at_cap(void) +{ + const char *path = "/tmp/shellclaw_test_tokens_cap.json"; + auth_ctx_t *ctx; + char *code; + char token[64]; + char oldest[TEST_TOKEN_HEX_LEN + 1]; + char newest_seed[TEST_TOKEN_HEX_LEN + 1]; + + unlink(path); + ctx = auth_init(path); + ASSERT(ctx != NULL); + code = auth_get_or_create_pairing_code(ctx); + ASSERT(code != NULL); + + /* + * Pairing requires a pending code from an empty/missing file, then we + * seed 16 tokens on disk before auth_pair appends (multi-device cap). + */ + ASSERT(write_token_array(path, TEST_AUTH_TOKEN_CAP) == 0); + format_dummy_token(oldest, sizeof(oldest), 0); + format_dummy_token(newest_seed, sizeof(newest_seed), TEST_AUTH_TOKEN_CAP - 1); + ASSERT(auth_validate_token(ctx, oldest) == 1); + ASSERT(auth_validate_token(ctx, newest_seed) == 1); + + memset(token, 0, sizeof(token)); + ASSERT(auth_pair(ctx, code, token, sizeof(token)) == 0); + ASSERT(strlen(token) == TEST_TOKEN_HEX_LEN); + ASSERT(auth_validate_token(ctx, token) == 1); + ASSERT(read_token_array_size(path) == TEST_AUTH_TOKEN_CAP); + ASSERT(auth_validate_token(ctx, oldest) == 0); + ASSERT(auth_validate_token(ctx, newest_seed) == 1); + + free(code); + auth_cleanup(ctx); + unlink(path); + return 0; +} + +static int test_auth_validate_token_rejects_length_mismatch(void) +{ + const char *path = "/tmp/shellclaw_test_tokens_len.json"; + auth_ctx_t *ctx; + char *code; + char token[64]; + char longer[80]; + + unlink(path); + ctx = auth_init(path); + ASSERT(ctx != NULL); + code = auth_get_or_create_pairing_code(ctx); + ASSERT(code != NULL); + memset(token, 0, sizeof(token)); + ASSERT(auth_pair(ctx, code, token, sizeof(token)) == 0); + ASSERT(auth_validate_token(ctx, token) == 1); + + snprintf(longer, sizeof(longer), "%sx", token); + ASSERT(auth_validate_token(ctx, longer) == 0); + + free(code); + auth_cleanup(ctx); + unlink(path); + return 0; +} + int main(void) { int failed = 0; @@ -242,6 +395,9 @@ int main(void) if (test_pair_lockout_clear_on_success() != 0) { fprintf(stderr, "test_pair_lockout_clear_on_success failed\n"); failed++; } if (test_pair_lockout_independent_ips() != 0) { fprintf(stderr, "test_pair_lockout_independent_ips failed\n"); failed++; } if (test_pair_lockout_null_ip() != 0) { fprintf(stderr, "test_pair_lockout_null_ip failed\n"); failed++; } + if (test_auth_pair_rejects_malformed_code() != 0) { fprintf(stderr, "test_auth_pair_rejects_malformed_code failed\n"); failed++; } + if (test_auth_pair_evicts_oldest_at_cap() != 0) { fprintf(stderr, "test_auth_pair_evicts_oldest_at_cap failed\n"); failed++; } + if (test_auth_validate_token_rejects_length_mismatch() != 0) { fprintf(stderr, "test_auth_validate_token_rejects_length_mismatch failed\n"); failed++; } if (failed == 0) printf("test_auth: all tests passed\n"); return failed; From ce3211c0808558f4466544863f7b727bb45574a2 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Fri, 10 Jul 2026 12:05:52 +0000 Subject: [PATCH 2/3] test(auth): cover lockout table full slot-0 eviction Pin lockout_find_or_create's full-table policy: the 33rd IP reuses slot 0, dropping the first IP's lockout while later slots stay locked. --- tests/test_auth.c | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_auth.c b/tests/test_auth.c index e538ddc..ee36ef8 100644 --- a/tests/test_auth.c +++ b/tests/test_auth.c @@ -379,6 +379,60 @@ static int test_auth_validate_token_rejects_length_mismatch(void) return 0; } +/** Must match LOCKOUT_TABLE_SIZE in src/gateway/auth.c */ +#define TEST_LOCKOUT_TABLE_SIZE 32 + +static void lockout_fill_ip(auth_ctx_t *ctx, const char *ip, time_t now) +{ + int i; + for (i = 0; i < PAIR_LOCKOUT_MAX_FAILS; i++) + auth_pair_record_failure(ctx, ip, now); +} + +/** + * When the lockout table is full, lockout_find_or_create reuses slot 0. + * That drops the first IP's lockout state so a 33rd attacker can proceed + * while later slots keep their lockouts. + */ +static int test_pair_lockout_table_full_evicts_slot_zero(void) +{ + time_t now = 6000; + auth_ctx_t *ctx = auth_init("/tmp/shellclaw_test_tokens_lockout6.json"); + char ip0[32]; + char ip1[32]; + char ip_last[32]; + char ip_overflow[32]; + int i; + + ASSERT(ctx != NULL); + for (i = 0; i < TEST_LOCKOUT_TABLE_SIZE; i++) { + char ip[32]; + snprintf(ip, sizeof(ip), "10.66.%d.%d", i / 256, i % 256); + lockout_fill_ip(ctx, ip, now); + } + + snprintf(ip0, sizeof(ip0), "10.66.0.0"); + snprintf(ip1, sizeof(ip1), "10.66.0.1"); + snprintf(ip_last, sizeof(ip_last), "10.66.0.%d", TEST_LOCKOUT_TABLE_SIZE - 1); + snprintf(ip_overflow, sizeof(ip_overflow), "10.66.1.0"); + + ASSERT(auth_pair_check_lockout(ctx, ip0, now) == 1); + ASSERT(auth_pair_check_lockout(ctx, ip1, now) == 1); + ASSERT(auth_pair_check_lockout(ctx, ip_last, now) == 1); + + lockout_fill_ip(ctx, ip_overflow, now); + + /* Slot 1+ must keep lockouts; only slot 0 was reused for the 33rd IP. */ + ASSERT(auth_pair_check_lockout(ctx, ip1, now) == 1); + ASSERT(auth_pair_check_lockout(ctx, ip_last, now) == 1); + ASSERT(auth_pair_check_lockout(ctx, ip_overflow, now) == 1); + /* Evicted IP loses prior lockout (check recreates a fresh slot-0 entry). */ + ASSERT(auth_pair_check_lockout(ctx, ip0, now) == 0); + + auth_cleanup(ctx); + return 0; +} + int main(void) { int failed = 0; @@ -398,6 +452,7 @@ int main(void) if (test_auth_pair_rejects_malformed_code() != 0) { fprintf(stderr, "test_auth_pair_rejects_malformed_code failed\n"); failed++; } if (test_auth_pair_evicts_oldest_at_cap() != 0) { fprintf(stderr, "test_auth_pair_evicts_oldest_at_cap failed\n"); failed++; } if (test_auth_validate_token_rejects_length_mismatch() != 0) { fprintf(stderr, "test_auth_validate_token_rejects_length_mismatch failed\n"); failed++; } + if (test_pair_lockout_table_full_evicts_slot_zero() != 0) { fprintf(stderr, "test_pair_lockout_table_full_evicts_slot_zero failed\n"); failed++; } if (failed == 0) printf("test_auth: all tests passed\n"); return failed; From 840d4efdac933c6cfdc8944316ca91cda53a05d4 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 13 Jul 2026 12:06:58 +0000 Subject: [PATCH 3/3] test(auth,gateway): cover pending-less pair and window slide guard Reject auth_pair when no pairing code was issued, and prove over-limit ASAP hits near the window edge do not extend window_start so clients still regain a full RPM allowance after the original window expires. --- tests/test_auth.c | 20 ++++++++++++++++++++ tests/test_rate_limit.c | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/tests/test_auth.c b/tests/test_auth.c index ee36ef8..aa7f48d 100644 --- a/tests/test_auth.c +++ b/tests/test_auth.c @@ -138,6 +138,25 @@ static int test_auth_pair_invalid_code(void) return 0; } +/* Fail closed when no pairing code was ever issued (tokens bootstrap skipped). */ +static int test_auth_pair_rejects_without_pending_code(void) +{ + const char *path = "/tmp/shellclaw_test_tokens_no_pending.json"; + auth_ctx_t *ctx; + char token[64]; + + unlink(path); + ctx = auth_init(path); + ASSERT(ctx != NULL); + memset(token, 0, sizeof(token)); + ASSERT(auth_pair(ctx, "123456", token, sizeof(token)) != 0); + ASSERT(token[0] == '\0'); + ASSERT(auth_validate_token(ctx, "123456") == 0); + auth_cleanup(ctx); + unlink(path); + return 0; +} + static int test_auth_validate_token(void) { unlink("/tmp/shellclaw_test_tokens_validate.json"); @@ -442,6 +461,7 @@ int main(void) if (test_auth_pair_valid_code() != 0) { fprintf(stderr, "test_auth_pair_valid_code failed\n"); failed++; } if (test_auth_pair_invalid_code() != 0) { fprintf(stderr, "test_auth_pair_invalid_code failed\n"); failed++; } if (test_auth_pairing_code_single_use() != 0) { fprintf(stderr, "test_auth_pairing_code_single_use failed\n"); failed++; } + if (test_auth_pair_rejects_without_pending_code() != 0) { fprintf(stderr, "test_auth_pair_rejects_without_pending_code failed\n"); failed++; } if (test_auth_validate_token() != 0) { fprintf(stderr, "test_auth_validate_token failed\n"); failed++; } if (test_auth_multi_token() != 0) { fprintf(stderr, "test_auth_multi_token failed\n"); failed++; } if (test_pair_lockout_triggers_after_max_fails() != 0) { fprintf(stderr, "test_pair_lockout_triggers_after_max_fails failed\n"); failed++; } diff --git a/tests/test_rate_limit.c b/tests/test_rate_limit.c index 11f2f21..dad21ac 100644 --- a/tests/test_rate_limit.c +++ b/tests/test_rate_limit.c @@ -98,6 +98,28 @@ static int test_long_ipv6_addresses_distinct(void) return 0; } +/** + * Over-limit hits must not slide window_start forward. Otherwise a client can + * keep the window alive forever by retrying near the edge and never regain + * a fresh ASAP_RATE_LIMIT_RPM allowance. + */ +static int test_limited_calls_do_not_extend_window(void) +{ + int i; + time_t t0 = 8000; + + rate_limit_reset(); + for (i = 0; i < ASAP_RATE_LIMIT_RPM; i++) + ASSERT(rate_limit_asap("10.0.0.6", t0) == 0); + ASSERT(rate_limit_asap("10.0.0.6", t0) == 1); + for (i = 0; i < 20; i++) + ASSERT(rate_limit_asap("10.0.0.6", t0 + ASAP_RATE_WINDOW_SECS - 1) == 1); + for (i = 0; i < ASAP_RATE_LIMIT_RPM; i++) + ASSERT(rate_limit_asap("10.0.0.6", t0 + ASAP_RATE_WINDOW_SECS) == 0); + ASSERT(rate_limit_asap("10.0.0.6", t0 + ASAP_RATE_WINDOW_SECS) == 1); + return 0; +} + int main(void) { int r = 0; @@ -108,6 +130,7 @@ int main(void) r |= test_partial_window_not_reset(); r |= test_table_full_fail_closed_preserves_first_ip(); r |= test_long_ipv6_addresses_distinct(); + r |= test_limited_calls_do_not_extend_window(); if (r == 0) printf("test_rate_limit: all tests passed\n"); return r; }