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/2] 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/2] 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;