conproxy 0.2.1

Cache proxy server for heterogeneous RAG/vector search backends with cascade, scope, and MCP
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! Security-focused E2E tests.
//!
//! Tests authentication bypass, rate limit enforcement, payload abuse,
//! admin access control, and header injection against a proxy with auth enabled.

use crate::helpers::client::E2eClient;
use crate::helpers::config::ConfigManager;
use crate::helpers::constants::{category_enabled, proxy_url};
use crate::helpers::mock_upstream::MockUpstream;
use crate::helpers::proxy::ProxyProcess;
use crate::helpers::report::TestReport;
use crate::run_test;
use std::path::PathBuf;
use std::time::Duration;

/// Run all security tests. Manages its own proxy with auth + rate limiting enabled.
pub fn run(report: &mut TestReport) {
    if !category_enabled("security") {
        return;
    }

    eprintln!();
    eprintln!("\x1b[1mSecurity Tests\x1b[0m");
    eprintln!("--------------------------------------------");

    let mock = MockUpstream::start();
    let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let mut config = ConfigManager::new(&project_root);
    write_security_config(&mut config, &mock.url());

    eprintln!("\x1b[32m[INFO]\x1b[0m Starting proxy with security config...");
    let mut proxy = ProxyProcess::start("127.0.0.1:8080");
    if let Err(e) = proxy.wait_healthy(Duration::from_secs(5)) {
        eprintln!("\x1b[31m[ERROR]\x1b[0m Security proxy failed to start: {e}");
        proxy.stop();
        config.restore();
        return;
    }

    let client_no_auth = E2eClient::new(proxy_url());
    let client_auth = E2eClient::new(proxy_url()).with_api_key("security-test-key");

    run_auth_bypass(&client_no_auth, &client_auth, report);
    run_rate_limit_enforcement(&client_auth, report);

    // Let the rate limiter fully refill before payload + admin tests
    // (burst_size=5, rps=10 → wait long enough after burst + payload traffic)
    std::thread::sleep(Duration::from_secs(3));

    run_payload_abuse(&client_auth, report);

    // Refill again after payload tests consume tokens
    std::thread::sleep(Duration::from_secs(2));

    run_admin_access_control(&client_no_auth, &client_auth, report);
    run_header_injection(&client_no_auth, report);

    proxy.stop();
    config.restore();

    eprintln!("--------------------------------------------");
}

/// Write a config suitable for security testing: auth enabled, rate limiting, body size limit.
fn write_security_config(config: &mut ConfigManager, mock_url: &str) {
    // Use the config manager's internal path by writing advanced-style config
    let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let config_path = project_root.join(".conproxy/conproxy.toml");
    if let Some(parent) = config_path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }

    // Back up via write_mock_config (triggers backup), then overwrite with our config
    config.write_mock_config(mock_url, "elasticsearch");

    let security_config = format!(
        r#"[server]
listen = "127.0.0.1:8080"

[upstreams.mock]
url = "{mock_url}"
type = "elasticsearch"
index = "mock_index"
search_fields = ["content", "title"]
timeout_secs = 5

[contexts.default]
default = true

[[contexts.default.upstreams]]
ref = "mock"
priority = 0
weight = 1

[contexts.default.cache]
fresh_secs = 60
stale_secs = 120
max_entries = 1000

[proxy]
api_key = "security-test-key"

[proxy.retry]
enabled = true
max_retries = 2
base_delay_ms = 50
max_delay_ms = 2000

[proxy.rate_limit]
enabled = true
requests_per_second = 10
burst_size = 5
"#
    );
    std::fs::write(&config_path, security_config).expect("Failed to write security config");
    eprintln!("  Wrote security test config (auth + rate_limit)");
}

// ---------------------------------------------------------------------------
// Auth bypass tests
// ---------------------------------------------------------------------------

fn run_auth_bypass(client_no_auth: &E2eClient, client_auth: &E2eClient, report: &mut TestReport) {
    eprintln!();
    eprintln!("\x1b[32m[INFO]\x1b[0m Auth bypass tests...");

    // No API key → 401
    run_test!(report, "security", "Auth bypass: no API key", {
        let (status, _) = client_no_auth.query_no_auth("bypass test");
        assert_eq!(status, 401, "Expected 401 without key, got {status}");
    });

    // Malformed key → 401
    run_test!(report, "security", "Auth bypass: malformed key", {
        let (status, _) = client_no_auth.query_with_key("bypass test", "");
        assert_eq!(status, 401, "Expected 401 with empty key, got {status}");
    });

    // Wrong key → 401
    run_test!(report, "security", "Auth bypass: wrong key", {
        let (status, _) = client_no_auth.query_with_key("bypass test", "wrong-key-value");
        assert_eq!(status, 401, "Expected 401 with wrong key, got {status}");
    });

    // Null bytes in key → 401
    // Note: reqwest rejects null bytes in header values at builder time, so
    // status=0 (builder error) is also acceptable — the proxy would reject
    // the key with 401 if the request were sent.
    run_test!(report, "security", "Auth bypass: null bytes in key", {
        let (status, _) = client_no_auth.query_with_key("bypass test", "security-test-key\0extra");
        assert!(
            status == 401 || status == 0,
            "Expected 401 (or builder-rejected) with null-byte key, got {status}"
        );
    });

    // Valid key → 200
    run_test!(report, "security", "Auth bypass: valid key accepted", {
        let (status, body) = client_auth.query("auth accepted test");
        assert_eq!(status, 200, "Expected 200 with valid key, got {status}");
        assert!(body["results"].is_array());
    });

    // Health endpoint accessible without auth (public)
    run_test!(report, "security", "Auth bypass: health is public", {
        let (status, _) = client_no_auth.health();
        assert_eq!(status, 200, "Health should be public, got {status}");
    });

    // Ready endpoint accessible without auth (public)
    run_test!(report, "security", "Auth bypass: ready is public", {
        let (status, _) = client_no_auth.ready();
        assert_eq!(status, 200, "Ready should be public, got {status}");
    });
}

// ---------------------------------------------------------------------------
// Rate limit enforcement tests
// ---------------------------------------------------------------------------

fn run_rate_limit_enforcement(client_auth: &E2eClient, report: &mut TestReport) {
    eprintln!();
    eprintln!("\x1b[32m[INFO]\x1b[0m Rate limit enforcement tests...");

    // Burst beyond limit → should get 429 eventually
    run_test!(report, "security", "Rate limit: burst triggers 429", {
        let mut got_429 = false;
        // Send 20 rapid-fire requests — with burst_size=5, should hit 429
        for i in 0..20 {
            let (status, _) = client_auth.query(&format!("rate-limit-burst-{i}"));
            if status == 429 {
                got_429 = true;
                break;
            }
        }
        assert!(
            got_429,
            "Expected at least one 429 after burst, but never got one"
        );
    });

    // Wait for bucket to refill
    std::thread::sleep(Duration::from_secs(2));

    // After refill, requests should succeed again
    run_test!(report, "security", "Rate limit: recovery after refill", {
        let (status, _) = client_auth.query("rate-limit-recovery");
        assert_eq!(
            status, 200,
            "Expected 200 after rate limit recovery, got {status}"
        );
    });
}

// ---------------------------------------------------------------------------
// Payload abuse tests
// ---------------------------------------------------------------------------

fn run_payload_abuse(client_auth: &E2eClient, report: &mut TestReport) {
    eprintln!();
    eprintln!("\x1b[32m[INFO]\x1b[0m Payload abuse tests...");

    // Deeply nested JSON
    run_test!(report, "security", "Payload: deeply nested JSON", {
        // Build deeply nested JSON: {"query": {"query": {"query": ... "deep" ...}}}
        let mut nested = serde_json::json!("deep");
        for _ in 0..100 {
            nested = serde_json::json!({"nested": nested});
        }
        let body = serde_json::json!({"query": "deep-nest-test", "metadata": nested});
        let (status, _) = client_auth.post_json("/query", &body);
        // Should either process it or reject it gracefully (not crash)
        assert!(
            status > 0,
            "Proxy should respond (not crash) on deeply nested JSON"
        );
        assert_ne!(status, 0, "Connection should not be dropped");
    });

    // Empty query string
    run_test!(report, "security", "Payload: empty query string", {
        let (status, _) = client_auth.query("");
        // Should handle gracefully — 200 or 400, not 500
        assert!(
            status == 200 || status == 400,
            "Expected 200 or 400 for empty query, got {status}"
        );
    });

    // Null bytes in query
    run_test!(report, "security", "Payload: null bytes in query", {
        let (status, _) = client_auth.query("test\0injection");
        assert!(status > 0, "Proxy should respond on null-byte query");
        assert_ne!(status, 500, "Should not cause internal server error");
    });

    // Very long query string
    run_test!(report, "security", "Payload: very long query string", {
        let long_query = "a".repeat(10_000);
        let (status, _) = client_auth.query(&long_query);
        // Should handle gracefully
        assert!(status > 0, "Proxy should respond on very long query");
    });

    // Malformed JSON body
    run_test!(report, "security", "Payload: malformed JSON body", {
        let url = format!("{}/query", proxy_url());
        let client = reqwest::blocking::Client::new();
        let resp = client
            .post(&url)
            .header("Content-Type", "application/json")
            .header("X-API-Key", "security-test-key")
            .body("{invalid json")
            .send();
        match resp {
            Ok(r) => {
                let status = r.status().as_u16();
                assert!(
                    status == 400 || status == 422,
                    "Expected 400 or 422 for malformed JSON, got {status}"
                );
            }
            Err(_) => {
                // Connection error is acceptable — proxy may reject early
            }
        }
    });
}

// ---------------------------------------------------------------------------
// Admin access control tests
// ---------------------------------------------------------------------------

fn run_admin_access_control(
    client_no_auth: &E2eClient,
    client_auth: &E2eClient,
    report: &mut TestReport,
) {
    eprintln!();
    eprintln!("\x1b[32m[INFO]\x1b[0m Admin access control tests...");

    // Admin endpoints without auth → 401
    run_test!(report, "security", "Admin ACL: pause without auth", {
        let (status, _) = client_no_auth.admin_pause();
        assert_eq!(status, 401, "Admin pause should require auth, got {status}");
    });

    run_test!(report, "security", "Admin ACL: resume without auth", {
        let (status, _) = client_no_auth.admin_resume();
        assert_eq!(
            status, 401,
            "Admin resume should require auth, got {status}"
        );
    });

    run_test!(report, "security", "Admin ACL: reload without auth", {
        let (status, _) = client_no_auth.admin_reload();
        assert_eq!(
            status, 401,
            "Admin reload should require auth, got {status}"
        );
    });

    run_test!(
        report,
        "security",
        "Admin ACL: metrics reset without auth",
        {
            let status = client_no_auth.admin_metrics_reset();
            assert_eq!(
                status, 401,
                "Admin metrics reset should require auth, got {status}"
            );
        }
    );

    // Admin endpoints with valid auth → 200
    run_test!(report, "security", "Admin ACL: pause with auth", {
        let (status, _) = client_auth.admin_pause();
        assert_eq!(
            status, 200,
            "Admin pause should succeed with auth, got {status}"
        );
        // Resume immediately
        let _ = client_auth.admin_resume();
    });
}

// ---------------------------------------------------------------------------
// Header injection tests
// ---------------------------------------------------------------------------

fn run_header_injection(client_no_auth: &E2eClient, report: &mut TestReport) {
    eprintln!();
    eprintln!("\x1b[32m[INFO]\x1b[0m Header injection tests...");

    // Newlines in X-API-Key header (CRLF injection attempt)
    run_test!(report, "security", "Header injection: CRLF in API key", {
        let (status, _) =
            client_no_auth.query_with_key("injection test", "key\r\nX-Injected: true");
        // Should be rejected — either 401 (bad key) or 400 (bad header)
        assert!(
            status == 401 || status == 400 || status == 0,
            "Expected rejection for CRLF injection, got {status}"
        );
    });

    // Very long header value
    run_test!(report, "security", "Header injection: oversized API key", {
        let long_key = "x".repeat(8192);
        let (status, _) = client_no_auth.query_with_key("injection test", &long_key);
        // Should be rejected — 401 (bad key) or 431 (header too large)
        assert!(
            status == 401 || status == 431 || status == 400 || status == 0,
            "Expected rejection for oversized key, got {status}"
        );
    });

    // Special characters in X-Request-Id (if supported)
    run_test!(
        report,
        "security",
        "Header injection: special chars in request ID",
        {
            let url = format!("{}/health", proxy_url());
            let client = reqwest::blocking::Client::new();
            let resp = client
                .get(&url)
                .header("X-Request-Id", "<script>alert(1)</script>")
                .send();
            match resp {
                Ok(r) => {
                    let status = r.status().as_u16();
                    // Should not crash — health is public
                    assert_eq!(status, 200, "Health should still work, got {status}");
                    // Response should not reflect the injected script
                    let body = r.text().unwrap_or_default();
                    assert!(
                        !body.contains("<script>"),
                        "Response should not reflect injected script"
                    );
                }
                Err(_) => {
                    // Connection error is acceptable
                }
            }
        }
    );
}