Skip to main content

max / makenotwork

17.1 KB · 494 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!(
251 r.status == 204 || r.status.is_client_error(),
252 "huge JWT key should produce 204 or a 4xx, never 5xx; got {}: {}",
253 r.status,
254 r.text,
255 );
256 }
257
258 // ── Attack 2: drift-job pathology ──
259
260 #[tokio::test]
261 async fn adversarial_drift_job_is_idempotent_when_already_consistent() {
262 // Running the drift job twice in a row with no schema change between must
263 // not flip rows. Second-run rows_affected == 0 by the `WHERE u.bytes_stored
264 // <> ...` predicate.
265 let (mut h, _blobs) = harness_with_billing_and_blobs().await;
266 let user_id = h.signup("adv_d", "adv_d@example.com", "Password1!").await;
267 let (app_id, _) = create_draft_app(&h.db, user_id).await;
268 sqlx::query(
269 "INSERT INTO sync_blobs (app_id, user_id, hash, s3_key, size_bytes, key)
270 VALUES ($1, $2, 'h1', $3, 500, 'k1')",
271 )
272 .bind(app_id)
273 .bind(user_id)
274 .bind(format!("{app_id}/{user_id}/h1"))
275 .execute(&h.db)
276 .await
277 .unwrap();
278
279 let n1 = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db)
280 .await
281 .unwrap();
282 let n2 = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db)
283 .await
284 .unwrap();
285 assert!(
286 n1 >= 1,
287 "first run should update at least one row, got {n1}"
288 );
289 assert_eq!(
290 n2, 0,
291 "second run on consistent state should be a no-op, got {n2}"
292 );
293 }
294
295 #[tokio::test]
296 async fn adversarial_drift_job_handles_empty_db() {
297 // No apps, no blobs. Must not panic, must return 0.
298 let h = TestHarness::with_mocks().await;
299 let n = makenotwork::db::synckit_billing::recalculate_synckit_app_storage(&h.db)
300 .await
301 .expect("recalculate on empty DB");
302 assert_eq!(n, 0);
303 }
304
305 // ── Attack 3: defensive ceiling vs per-key cap ──
306
307 #[tokio::test]
308 async fn adversarial_defensive_aggregate_ceiling_trips_on_drifted_counters() {
309 // Drift the app counter ABOVE the aggregate cap with the per-key counter
310 // still under. The per-key check returns Ok (under per-key cap), so the
311 // defensive aggregate ceiling must trip, otherwise the per_key mode
312 // silently admits writes far past what the developer paid for.
313 let (mut h, blobs) = harness_with_billing_and_blobs().await;
314 let user_id = h
315 .signup("adv_agg", "adv_agg@example.com", "Password1!")
316 .await;
317 let (app_id, _api_key) = create_draft_app(&h.db, user_id).await;
318 activate_per_key(&mut h, app_id, 2, 1).await; // app cap = 2 GiB
319 claim_key(&mut h, "A").await;
320
321 // App counter drifted past aggregate cap (2 GiB).
322 sqlx::query("UPDATE sync_app_usage_current SET bytes_stored = $2 WHERE app_id = $1")
323 .bind(app_id)
324 .bind(3 * GIB)
325 .execute(&h.db)
326 .await
327 .unwrap();
328 // Per-key A counter is well under per-key cap (1 GiB).
329 sqlx::query(
330 "INSERT INTO sync_key_usage_current (app_id, key, bytes_stored) VALUES ($1, 'A', $2)",
331 )
332 .bind(app_id)
333 .bind(100i64)
334 .execute(&h.db)
335 .await
336 .unwrap();
337
338 auth_as(&mut h, user_id, app_id, "A");
339 let hash = fake_hash(0x04);
340 let s3_key = format!("{}/{}/{}", app_id, user_id, &hash);
341 blobs.put(&s3_key, vec![0u8; 8]);
342
343 h.client
344 .post_json(
345 "/api/sync/blobs/upload",
346 &json!({ "hash": hash, "size_bytes": 1 }).to_string(),
347 )
348 .await;
349 let r = h
350 .client
351 .post_json(
352 "/api/sync/blobs/confirm",
353 &json!({ "hash": hash, "size_bytes": 1 }).to_string(),
354 )
355 .await;
356 assert_eq!(
357 r.status, 402,
358 "defensive aggregate ceiling should trip: {}",
359 r.text
360 );
361 let body: serde_json::Value = serde_json::from_str(&r.text).unwrap();
362 assert_eq!(
363 body["dimension"], "storage",
364 "expected app-aggregate dimension, got {body:?}"
365 );
366 }
367
368 // ── Attack 4: pricing arithmetic ──
369
370 #[tokio::test]
371 async fn adversarial_activate_with_huge_storage_does_not_panic() {
372 // The DB column `storage_gb_cap` is INT (i32), so u32::MAX would overflow
373 // on insert. The validator caps via u32 → i32 cast on the DB write path:
374 // we expect either a 400 from validate_knobs (unlikely, no upper bound)
375 // or a 500/4xx from the DB write, but NOT a panic.
376 let mut h = TestHarness::with_mocks().await;
377 let user_id = h.signup("adv_p", "adv_p@example.com", "Password1!").await;
378 let (app_id, _) = create_draft_app(&h.db, user_id).await;
379 h.client
380 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
381 .await;
382 // i32::MAX as u32, this is the largest value that round-trips through
383 // the `u32 as i32` cast in activate_billing.
384 let big = i32::MAX as u32;
385 let r = h
386 .client
387 .post_json(
388 &format!("/api/sync/apps/{app_id}/billing/activate"),
389 &json!({ "enforcement_mode": "bulk", "storage_gb_cap": big }).to_string(),
390 )
391 .await;
392 assert!(
393 r.status == 200 || r.status.is_client_error(),
394 "huge storage_gb_cap should produce 200 or 4xx, never 5xx; got {}: {}",
395 r.status,
396 r.text,
397 );
398 }
399
400 // ── Attack 5: claim/release/reclaim sequence ──
401
402 #[tokio::test]
403 async fn adversarial_release_then_reclaim_does_not_double_count() {
404 // Claim → release → reclaim of the same key. Each transition adjusts
405 // sync_app_usage_current.keys_claimed; net effect should be +1, not +2.
406 let mut h = TestHarness::with_mocks().await;
407 let user_id = h.signup("adv_r", "adv_r@example.com", "Password1!").await;
408 let (app_id, _api_key) = create_draft_app(&h.db, user_id).await;
409 h.client
410 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
411 .await;
412 h.client
413 .post_json(
414 &format!("/api/sync/apps/{app_id}/billing/activate"),
415 &json!({ "enforcement_mode": "per_key", "key_cap": 2, "gb_per_key": 1 }).to_string(),
416 )
417 .await;
418
419 claim_key(&mut h, "K").await;
420 let r = h
421 .client
422 .post_json(
423 "/api/sync/keys/release",
424 &json!({ "app_secret": APP_SECRET, "key": "K" }).to_string(),
425 )
426 .await;
427 assert_eq!(r.status, 200, "release: {}", r.text);
428 claim_key(&mut h, "K").await;
429
430 let total: i32 =
431 sqlx::query_scalar("SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1")
432 .bind(app_id)
433 .fetch_one(&h.db)
434 .await
435 .unwrap();
436 assert_eq!(total, 1, "claim/release/reclaim must net to 1, got {total}");
437 }
438
439 // ── Attack 6: the shipped api_key as a keys-endpoint credential ──
440
441 #[tokio::test]
442 async fn adversarial_api_key_cannot_drive_keys_endpoints() {
443 // The api_key is compiled into every shipped client (`include_str!`), so
444 // anyone with a binary has it. It must not authenticate claim/release/list,
445 // or a stranger could exhaust a per_key app's cap under the app's identity.
446 let mut h = TestHarness::with_mocks().await;
447 let user_id = h.signup("adv_ak", "adv_ak@example.com", "Password1!").await;
448 let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
449 h.client
450 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
451 .await;
452 h.client
453 .post_json(
454 &format!("/api/sync/apps/{app_id}/billing/activate"),
455 &json!({ "enforcement_mode": "per_key", "key_cap": 2, "gb_per_key": 1 }).to_string(),
456 )
457 .await;
458
459 // Named as app_secret, but carrying the api_key value: wrong credential.
460 for path in [
461 "/api/sync/keys/claim",
462 "/api/sync/keys/release",
463 "/api/sync/keys/list",
464 ] {
465 let r = h
466 .client
467 .post_json(
468 path,
469 &json!({ "app_secret": api_key, "key": "X" }).to_string(),
470 )
471 .await;
472 assert_eq!(r.status, 401, "{path} accepted the api_key: {}", r.text);
473 }
474
475 // The legacy field name is not a way back in either.
476 let r = h
477 .client
478 .post_json(
479 "/api/sync/keys/claim",
480 &json!({ "api_key": api_key, "key": "X" }).to_string(),
481 )
482 .await;
483 assert_ne!(r.status, 200, "legacy api_key body accepted: {}", r.text);
484
485 // Nothing was claimed by any of the above.
486 let total: i32 =
487 sqlx::query_scalar("SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1")
488 .bind(app_id)
489 .fetch_one(&h.db)
490 .await
491 .unwrap();
492 assert_eq!(total, 0, "rejected calls must not claim a key, got {total}");
493 }
494