Skip to main content

max / makenotwork

14.4 KB · 405 lines History Blame Raw
1 //! Adversarial tests for SyncKit v2 billing + per-key storage (v0.7.0+).
2 //!
3 //! These tests don't exist to confirm the happy path — they exist to BREAK
4 //! things. Hostile JWT payloads, pathological pricing inputs, weird mode
5 //! transitions, drift-job pathology. Each test states what it's trying to
6 //! attack and what surviving means.
7
8 use crate::harness::{BuildOptions, TestHarness, stripe, storage::InMemoryStorage};
9 use makenotwork::db::{SyncAppId, UserId};
10 use serde_json::json;
11 use sqlx::PgPool;
12 use std::sync::Arc;
13
14 const GIB: i64 = 1024 * 1024 * 1024;
15
16 async fn harness_with_billing_and_blobs() -> (TestHarness, Arc<InMemoryStorage>) {
17 let synckit_mem = Arc::new(InMemoryStorage::new());
18 let mock_stripe = Arc::new(stripe::MockPaymentProvider::new());
19 let mock_email = Arc::new(crate::harness::email::MockEmailTransport::new());
20 let mut h = TestHarness::build(BuildOptions {
21 synckit_storage: Some(synckit_mem.clone()),
22 stripe_client: Some(mock_stripe.clone()),
23 mock_email: Some(mock_email),
24 ..Default::default()
25 })
26 .await;
27 h.mock_stripe = Some(mock_stripe);
28 (h, synckit_mem)
29 }
30
31 async fn create_draft_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
32 let api_key = "test-api-key-adv";
33 let key_hash = crate::harness::hash_api_key(api_key);
34 let key_prefix = &api_key[..8];
35 let app_id: SyncAppId = sqlx::query_scalar(
36 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status)
37 VALUES ($1, 'AdvTest', $2, $3, FALSE, 'draft')
38 RETURNING id",
39 )
40 .bind(user_id)
41 .bind(&key_hash)
42 .bind(key_prefix)
43 .fetch_one(pool)
44 .await
45 .expect("insert sync_app");
46 sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT DO NOTHING")
47 .bind(app_id)
48 .execute(pool)
49 .await
50 .unwrap();
51 (app_id, api_key.to_string())
52 }
53
54 async fn activate_per_key(h: &mut TestHarness, app_id: SyncAppId, key_cap: u32, gb_per_key: u32) {
55 h.client
56 .post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "")
57 .await;
58 let resp = h
59 .client
60 .post_json(
61 &format!("/api/sync/apps/{}/billing/activate", app_id),
62 &json!({
63 "enforcement_mode": "per_key",
64 "key_cap": key_cap,
65 "gb_per_key": gb_per_key,
66 })
67 .to_string(),
68 )
69 .await;
70 assert_eq!(resp.status, 200, "activate per_key: {}", resp.text);
71 }
72
73 async fn claim_key(h: &mut TestHarness, api_key: &str, key: &str) {
74 let resp = h
75 .client
76 .post_json(
77 "/api/sync/keys/claim",
78 &json!({ "api_key": api_key, "key": key }).to_string(),
79 )
80 .await;
81 assert_eq!(resp.status, 200, "claim {}: {}", key, resp.text);
82 }
83
84 fn auth_as(h: &mut TestHarness, user_id: UserId, app_id: SyncAppId, key: &str) {
85 let token = makenotwork::synckit_auth::create_sync_token(
86 "test-synckit-jwt-secret",
87 user_id,
88 app_id,
89 key,
90 )
91 .expect("mint test JWT");
92 h.client.set_bearer_token(&token);
93 }
94
95 fn fake_hash(seed: u8) -> String {
96 let mut s = String::with_capacity(64);
97 for _ in 0..32 {
98 s.push_str(&format!("{:02x}", seed));
99 }
100 s
101 }
102
103 // ── Attack 1: hostile JWT key payloads ──
104 //
105 // The JWT extractor (SyncUser::from_request_parts) only rejects an empty `key`.
106 // validate_synckit_key (which bans null bytes, oversize, control chars) runs
107 // only on the /api/sync/auth route. A developer who mints their own JWT
108 // (allowed: keys come from THEIR backend) can sneak hostile values past every
109 // validator. These tests prove the rest of the stack survives. Surviving
110 // means: parameterized queries don't break, presigned URLs build, and no
111 // route panics or 500s — even if the upload eventually gets rejected.
112
113 #[tokio::test]
114 async fn adversarial_jwt_key_with_sql_injection_literal() {
115 // Parameterized queries (sqlx) should treat this as literal data.
116 let (mut h, blobs) = harness_with_billing_and_blobs().await;
117 let user_id = h.signup("adv_sql", "adv_sql@example.com", "Password1!").await;
118 let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
119 activate_per_key(&mut h, app_id, 5, 1).await;
120
121 let evil = "k'; DROP TABLE sync_apps; --";
122 claim_key(&mut h, &api_key, evil).await;
123
124 auth_as(&mut h, user_id, app_id, evil);
125 let hash = fake_hash(0x01);
126 let s3_key = format!("{}/{}/{}", app_id, user_id, &hash);
127 blobs.put(&s3_key, vec![0u8; 8]);
128
129 let r = h
130 .client
131 .post_json(
132 "/api/sync/blobs/upload",
133 &json!({ "hash": hash, "size_bytes": 1024 }).to_string(),
134 )
135 .await;
136 assert_eq!(r.status, 200, "upload-url should not 500: {}", r.text);
137
138 let r = h
139 .client
140 .post_json(
141 "/api/sync/blobs/confirm",
142 &json!({ "hash": hash, "size_bytes": 1024 }).to_string(),
143 )
144 .await;
145 assert_eq!(r.status, 204, "confirm should succeed safely: {}", r.text);
146
147 // Table still exists.
148 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync_apps WHERE id = $1")
149 .bind(app_id)
150 .fetch_one(&h.db)
151 .await
152 .unwrap();
153 assert_eq!(count, 1, "sync_apps row must still exist after injection-flavored key");
154
155 // Per-key counter row reflects the evil key (literal storage).
156 let stored: Option<i64> = sqlx::query_scalar(
157 "SELECT bytes_stored FROM sync_key_usage_current WHERE app_id = $1 AND key = $2",
158 )
159 .bind(app_id)
160 .bind(evil)
161 .fetch_one(&h.db)
162 .await
163 .unwrap();
164 assert_eq!(stored, Some(1024));
165 }
166
167 #[tokio::test]
168 async fn adversarial_jwt_key_with_unicode_rtl_and_zero_width() {
169 // RTL override + zero-width joiner. Should survive end-to-end.
170 let (mut h, blobs) = harness_with_billing_and_blobs().await;
171 let user_id = h.signup("adv_u", "adv_u@example.com", "Password1!").await;
172 let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
173 activate_per_key(&mut h, app_id, 5, 1).await;
174
175 let weird = "user\u{202E}admin\u{200B}";
176 claim_key(&mut h, &api_key, weird).await;
177 auth_as(&mut h, user_id, app_id, weird);
178
179 let hash = fake_hash(0x02);
180 let s3_key = format!("{}/{}/{}", app_id, user_id, &hash);
181 blobs.put(&s3_key, vec![0u8; 8]);
182
183 h.client
184 .post_json(
185 "/api/sync/blobs/upload",
186 &json!({ "hash": hash, "size_bytes": 16 }).to_string(),
187 )
188 .await;
189 let r = h
190 .client
191 .post_json(
192 "/api/sync/blobs/confirm",
193 &json!({ "hash": hash, "size_bytes": 16 }).to_string(),
194 )
195 .await;
196 assert_eq!(r.status, 204, "weird unicode key should still upload: {}", r.text);
197 }
198
199 #[tokio::test]
200 async fn adversarial_jwt_key_extremely_long() {
201 // 32 KiB JWT key. The /api/sync/auth route would 400; a directly-minted
202 // token slips it past the validator. This test pins current behavior so
203 // that a future bounded-length check on the extractor side will surface
204 // here as a deliberate change (and not break silently).
205 let (mut h, blobs) = harness_with_billing_and_blobs().await;
206 let user_id = h.signup("adv_l", "adv_l@example.com", "Password1!").await;
207 let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
208 activate_per_key(&mut h, app_id, 5, 1).await;
209
210 let huge = "k".repeat(32 * 1024);
211 claim_key(&mut h, &api_key, &huge).await;
212 auth_as(&mut h, user_id, app_id, &huge);
213
214 let hash = fake_hash(0x03);
215 let s3_key = format!("{}/{}/{}", app_id, user_id, &hash);
216 blobs.put(&s3_key, vec![0u8; 8]);
217 h.client
218 .post_json(
219 "/api/sync/blobs/upload",
220 &json!({ "hash": hash, "size_bytes": 32 }).to_string(),
221 )
222 .await;
223 let r = h
224 .client
225 .post_json(
226 "/api/sync/blobs/confirm",
227 &json!({ "hash": hash, "size_bytes": 32 }).to_string(),
228 )
229 .await;
230 // We don't assert success — DB index size limits could legitimately reject
231 // the row. We DO assert the server doesn't 500.
232 assert!(
233 r.status == 204 || r.status.is_client_error(),
234 "huge JWT key should produce 204 or a 4xx, never 5xx; got {}: {}",
235 r.status, r.text,
236 );
237 }
238
239 // ── Attack 2: drift-job pathology ──
240
241 #[tokio::test]
242 async fn adversarial_drift_job_is_idempotent_when_already_consistent() {
243 // Running the drift job twice in a row with no schema change between must
244 // not flip rows. Second-run rows_affected == 0 by the `WHERE u.bytes_stored
245 // <> ...` predicate.
246 let (mut h, _blobs) = harness_with_billing_and_blobs().await;
247 let user_id = h.signup("adv_d", "adv_d@example.com", "Password1!").await;
248 let (app_id, _) = create_draft_app(&h.db, user_id).await;
249 sqlx::query(
250 "INSERT INTO sync_blobs (app_id, user_id, hash, s3_key, size_bytes, key)
251 VALUES ($1, $2, 'h1', $3, 500, 'k1')",
252 )
253 .bind(app_id)
254 .bind(user_id)
255 .bind(format!("{}/{}/h1", app_id, user_id))
256 .execute(&h.db)
257 .await
258 .unwrap();
259
260 let n1 = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db)
261 .await
262 .unwrap();
263 let n2 = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db)
264 .await
265 .unwrap();
266 assert!(n1 >= 1, "first run should update at least one row, got {n1}");
267 assert_eq!(n2, 0, "second run on consistent state should be a no-op, got {n2}");
268 }
269
270 #[tokio::test]
271 async fn adversarial_drift_job_handles_empty_db() {
272 // No apps, no blobs. Must not panic, must return 0.
273 let h = TestHarness::with_mocks().await;
274 let n = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db)
275 .await
276 .expect("recalculate on empty DB");
277 assert_eq!(n, 0);
278 }
279
280 // ── Attack 3: defensive ceiling vs per-key cap ──
281
282 #[tokio::test]
283 async fn adversarial_defensive_aggregate_ceiling_trips_on_drifted_counters() {
284 // Drift the app counter ABOVE the aggregate cap with the per-key counter
285 // still under. The per-key check returns Ok (under per-key cap), so the
286 // defensive aggregate ceiling must trip — otherwise the per_key mode
287 // silently admits writes far past what the developer paid for.
288 let (mut h, blobs) = harness_with_billing_and_blobs().await;
289 let user_id = h.signup("adv_agg", "adv_agg@example.com", "Password1!").await;
290 let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
291 activate_per_key(&mut h, app_id, 2, 1).await; // app cap = 2 GiB
292 claim_key(&mut h, &api_key, "A").await;
293
294 // App counter drifted past aggregate cap (2 GiB).
295 sqlx::query("UPDATE sync_app_usage_current SET bytes_stored = $2 WHERE app_id = $1")
296 .bind(app_id)
297 .bind(3 * GIB)
298 .execute(&h.db)
299 .await
300 .unwrap();
301 // Per-key A counter is well under per-key cap (1 GiB).
302 sqlx::query(
303 "INSERT INTO sync_key_usage_current (app_id, key, bytes_stored) VALUES ($1, 'A', $2)",
304 )
305 .bind(app_id)
306 .bind(100i64)
307 .execute(&h.db)
308 .await
309 .unwrap();
310
311 auth_as(&mut h, user_id, app_id, "A");
312 let hash = fake_hash(0x04);
313 let s3_key = format!("{}/{}/{}", app_id, user_id, &hash);
314 blobs.put(&s3_key, vec![0u8; 8]);
315
316 h.client
317 .post_json(
318 "/api/sync/blobs/upload",
319 &json!({ "hash": hash, "size_bytes": 1 }).to_string(),
320 )
321 .await;
322 let r = h
323 .client
324 .post_json(
325 "/api/sync/blobs/confirm",
326 &json!({ "hash": hash, "size_bytes": 1 }).to_string(),
327 )
328 .await;
329 assert_eq!(r.status, 402, "defensive aggregate ceiling should trip: {}", r.text);
330 let body: serde_json::Value = serde_json::from_str(&r.text).unwrap();
331 assert_eq!(body["dimension"], "storage", "expected app-aggregate dimension, got {:?}", body);
332 }
333
334 // ── Attack 4: pricing arithmetic ──
335
336 #[tokio::test]
337 async fn adversarial_activate_with_huge_storage_does_not_panic() {
338 // The DB column `storage_gb_cap` is INT (i32), so u32::MAX would overflow
339 // on insert. The validator caps via u32 → i32 cast on the DB write path:
340 // we expect either a 400 from validate_knobs (unlikely, no upper bound)
341 // or a 500/4xx from the DB write — but NOT a panic.
342 let mut h = TestHarness::with_mocks().await;
343 let user_id = h.signup("adv_p", "adv_p@example.com", "Password1!").await;
344 let (app_id, _) = create_draft_app(&h.db, user_id).await;
345 h.client
346 .post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "")
347 .await;
348 // i32::MAX as u32 — this is the largest value that round-trips through
349 // the `u32 as i32` cast in activate_billing.
350 let big = i32::MAX as u32;
351 let r = h
352 .client
353 .post_json(
354 &format!("/api/sync/apps/{}/billing/activate", app_id),
355 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": big }).to_string(),
356 )
357 .await;
358 assert!(
359 r.status == 200 || r.status.is_client_error(),
360 "huge storage_gb_cap should produce 200 or 4xx, never 5xx; got {}: {}",
361 r.status, r.text,
362 );
363 }
364
365 // ── Attack 5: claim/release/reclaim sequence ──
366
367 #[tokio::test]
368 async fn adversarial_release_then_reclaim_does_not_double_count() {
369 // Claim → release → reclaim of the same key. Each transition adjusts
370 // sync_app_usage_current.keys_claimed; net effect should be +1, not +2.
371 let mut h = TestHarness::with_mocks().await;
372 let user_id = h.signup("adv_r", "adv_r@example.com", "Password1!").await;
373 let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
374 h.client
375 .post_json(&format!("/api/sync/apps/{}/billing/setup", app_id), "")
376 .await;
377 h.client
378 .post_json(
379 &format!("/api/sync/apps/{}/billing/activate", app_id),
380 &json!({ "enforcement_mode": "per_key", "key_cap": 2, "gb_per_key": 1 })
381 .to_string(),
382 )
383 .await;
384
385 claim_key(&mut h, &api_key, "K").await;
386 let r = h
387 .client
388 .post_json(
389 "/api/sync/keys/release",
390 &json!({ "api_key": api_key, "key": "K" }).to_string(),
391 )
392 .await;
393 assert_eq!(r.status, 200, "release: {}", r.text);
394 claim_key(&mut h, &api_key, "K").await;
395
396 let total: i32 = sqlx::query_scalar(
397 "SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1",
398 )
399 .bind(app_id)
400 .fetch_one(&h.db)
401 .await
402 .unwrap();
403 assert_eq!(total, 1, "claim/release/reclaim must net to 1, got {total}");
404 }
405