Skip to main content

max / makenotwork

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