Skip to main content

max / makenotwork

17.0 KB · 493 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 payment_caps: makenotwork::payments::PaymentCapabilities::all(mock_stripe.clone()),
25 mock_email: Some(mock_email),
26 ..Default::default()
27 })
28 .await;
29 h.mock_stripe = Some(mock_stripe);
30 (h, synckit_mem)
31 }
32 /// Keys-endpoint secret seeded alongside the api_key. A distinct value on
33 /// purpose: the api_key ships inside client binaries and must not authenticate
34 /// `/api/sync/keys/*`.
35 const APP_SECRET: &str = "test-app-secret-adv";
36
37 async fn create_draft_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
38 let api_key = "test-api-key-adv";
39 let key_hash = crate::harness::hash_api_key(api_key);
40 let key_prefix = &api_key[..8];
41 let app_id: SyncAppId = sqlx::query_scalar(
42 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, keys_secret_hash, keys_secret_prefix, is_internal, billing_status)
43 VALUES ($1, 'AdvTest', $2, $3, $4, $5, FALSE, 'draft')
44 RETURNING id",
45 )
46 .bind(user_id)
47 .bind(&key_hash)
48 .bind(key_prefix)
49 .bind(crate::harness::hash_api_key(APP_SECRET))
50 .bind(&APP_SECRET[..8])
51 .fetch_one(pool)
52 .await
53 .expect("insert sync_app");
54 sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT DO NOTHING")
55 .bind(app_id)
56 .execute(pool)
57 .await
58 .unwrap();
59 (app_id, api_key.to_string())
60 }
61
62 async fn activate_per_key(h: &mut TestHarness, app_id: SyncAppId, key_cap: u32, gb_per_key: u32) {
63 h.client
64 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
65 .await;
66 let resp = h
67 .client
68 .post_json(
69 &format!("/api/sync/apps/{app_id}/billing/activate"),
70 &json!({
71 "enforcement_mode": "per_key",
72 "key_cap": key_cap,
73 "gb_per_key": gb_per_key,
74 })
75 .to_string(),
76 )
77 .await;
78 assert_eq!(resp.status, 200, "activate per_key: {}", resp.text);
79 }
80
81 async fn claim_key(h: &mut TestHarness, key: &str) {
82 let resp = h
83 .client
84 .post_json(
85 "/api/sync/keys/claim",
86 &json!({ "app_secret": APP_SECRET, "key": key }).to_string(),
87 )
88 .await;
89 assert_eq!(resp.status, 200, "claim {}: {}", key, resp.text);
90 }
91
92 fn auth_as(h: &mut TestHarness, user_id: UserId, app_id: SyncAppId, key: &str) {
93 let token = makenotwork::synckit_auth::create_sync_token(
94 "test-synckit-jwt-secret",
95 user_id,
96 app_id,
97 key,
98 )
99 .expect("mint test JWT");
100 h.client.set_bearer_token(&token);
101 }
102
103 fn fake_hash(seed: u8) -> String {
104 let mut s = String::with_capacity(64);
105 for _ in 0..32 {
106 write!(s, "{seed:02x}").unwrap();
107 }
108 s
109 }
110
111 // ── Attack 1: hostile JWT key payloads ──
112 //
113 // The JWT extractor (SyncUser::from_request_parts) only rejects an empty `key`.
114 // validate_synckit_key (which bans null bytes, oversize, control chars) runs
115 // only on the /api/sync/auth route. A developer who mints their own JWT
116 // (allowed: keys come from THEIR backend) can sneak hostile values past every
117 // validator. These tests prove the rest of the stack survives. Surviving
118 // means: parameterized queries don't break, presigned URLs build, and no
119 // route panics or 500s, even if the upload eventually gets rejected.
120
121 #[tokio::test]
122 async fn adversarial_jwt_key_with_sql_injection_literal() {
123 // Parameterized queries (sqlx) should treat this as literal data.
124 let (mut h, blobs) = harness_with_billing_and_blobs().await;
125 let user_id = h
126 .signup("adv_sql", "adv_sql@example.com", "Password1!")
127 .await;
128 let (app_id, _api_key) = create_draft_app(&h.db, user_id).await;
129 activate_per_key(&mut h, app_id, 5, 1).await;
130
131 let evil = "k'; DROP TABLE sync_apps; --";
132 claim_key(&mut h, evil).await;
133
134 auth_as(&mut h, user_id, app_id, evil);
135 let hash = fake_hash(0x01);
136 let s3_key = format!("{app_id}/{user_id}/{hash}");
137 // Confirm records the authoritative S3 object size, so store the full
138 // declared count to keep the per-key counter assertion below meaningful.
139 blobs.put(&s3_key, vec![0u8; 1024]);
140
141 let r = h
142 .client
143 .post_json(
144 "/api/sync/blobs/upload",
145 &json!({ "hash": hash, "size_bytes": 1024 }).to_string(),
146 )
147 .await;
148 assert_eq!(r.status, 200, "upload-url should not 500: {}", r.text);
149
150 let r = h
151 .client
152 .post_json(
153 "/api/sync/blobs/confirm",
154 &json!({ "hash": hash, "size_bytes": 1024 }).to_string(),
155 )
156 .await;
157 assert_eq!(r.status, 204, "confirm should succeed safely: {}", r.text);
158
159 // Table still exists.
160 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync_apps WHERE id = $1")
161 .bind(app_id)
162 .fetch_one(&h.db)
163 .await
164 .unwrap();
165 assert_eq!(
166 count, 1,
167 "sync_apps row must still exist after injection-flavored key"
168 );
169
170 // Per-key counter row reflects the evil key (literal storage).
171 let stored: Option<i64> = sqlx::query_scalar(
172 "SELECT bytes_stored FROM sync_key_usage_current WHERE app_id = $1 AND key = $2",
173 )
174 .bind(app_id)
175 .bind(evil)
176 .fetch_one(&h.db)
177 .await
178 .unwrap();
179 assert_eq!(stored, Some(1024));
180 }
181
182 #[tokio::test]
183 async fn adversarial_jwt_key_with_unicode_rtl_and_zero_width() {
184 // RTL override + zero-width joiner. Should survive end-to-end.
185 let (mut h, blobs) = harness_with_billing_and_blobs().await;
186 let user_id = h.signup("adv_u", "adv_u@example.com", "Password1!").await;
187 let (app_id, _api_key) = create_draft_app(&h.db, user_id).await;
188 activate_per_key(&mut h, app_id, 5, 1).await;
189
190 let weird = "user\u{202E}admin\u{200B}";
191 claim_key(&mut h, weird).await;
192 auth_as(&mut h, user_id, app_id, weird);
193
194 let hash = fake_hash(0x02);
195 let s3_key = format!("{app_id}/{user_id}/{hash}");
196 blobs.put(&s3_key, vec![0u8; 8]);
197
198 h.client
199 .post_json(
200 "/api/sync/blobs/upload",
201 &json!({ "hash": hash, "size_bytes": 16 }).to_string(),
202 )
203 .await;
204 let r = h
205 .client
206 .post_json(
207 "/api/sync/blobs/confirm",
208 &json!({ "hash": hash, "size_bytes": 16 }).to_string(),
209 )
210 .await;
211 assert_eq!(
212 r.status, 204,
213 "weird unicode key should still upload: {}",
214 r.text
215 );
216 }
217
218 #[tokio::test]
219 async fn adversarial_jwt_key_extremely_long() {
220 // 32 KiB JWT key. The /api/sync/auth route would 400; a directly-minted
221 // token slips it past the validator. This test pins current behavior so
222 // that a future bounded-length check on the extractor side will surface
223 // here as a deliberate change (and not break silently).
224 let (mut h, blobs) = harness_with_billing_and_blobs().await;
225 let user_id = h.signup("adv_l", "adv_l@example.com", "Password1!").await;
226 let (app_id, _api_key) = create_draft_app(&h.db, user_id).await;
227 activate_per_key(&mut h, app_id, 5, 1).await;
228
229 let huge = "k".repeat(32 * 1024);
230 claim_key(&mut h, &huge).await;
231 auth_as(&mut h, user_id, app_id, &huge);
232
233 let hash = fake_hash(0x03);
234 let s3_key = format!("{app_id}/{user_id}/{hash}");
235 blobs.put(&s3_key, vec![0u8; 8]);
236 h.client
237 .post_json(
238 "/api/sync/blobs/upload",
239 &json!({ "hash": hash, "size_bytes": 32 }).to_string(),
240 )
241 .await;
242 let r = h
243 .client
244 .post_json(
245 "/api/sync/blobs/confirm",
246 &json!({ "hash": hash, "size_bytes": 32 }).to_string(),
247 )
248 .await;
249 // We don't assert success, DB index size limits could legitimately reject
250 // the row. We DO assert the server doesn't 500.
251 assert_eq!(
252 r.status, 204,
253 "huge JWT key should produce 204 or a 4xx, never 5xx; got {}: {}",
254 r.status, 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_eq!(
393 r.status, 400,
394 "huge storage_gb_cap should produce 200 or 4xx, never 5xx; got {}: {}",
395 r.status, r.text,
396 );
397 }
398
399 // ── Attack 5: claim/release/reclaim sequence ──
400
401 #[tokio::test]
402 async fn adversarial_release_then_reclaim_does_not_double_count() {
403 // Claim → release → reclaim of the same key. Each transition adjusts
404 // sync_app_usage_current.keys_claimed; net effect should be +1, not +2.
405 let mut h = TestHarness::with_mocks().await;
406 let user_id = h.signup("adv_r", "adv_r@example.com", "Password1!").await;
407 let (app_id, _api_key) = create_draft_app(&h.db, user_id).await;
408 h.client
409 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
410 .await;
411 h.client
412 .post_json(
413 &format!("/api/sync/apps/{app_id}/billing/activate"),
414 &json!({ "enforcement_mode": "per_key", "key_cap": 2, "gb_per_key": 1 }).to_string(),
415 )
416 .await;
417
418 claim_key(&mut h, "K").await;
419 let r = h
420 .client
421 .post_json(
422 "/api/sync/keys/release",
423 &json!({ "app_secret": APP_SECRET, "key": "K" }).to_string(),
424 )
425 .await;
426 assert_eq!(r.status, 200, "release: {}", r.text);
427 claim_key(&mut h, "K").await;
428
429 let total: i32 =
430 sqlx::query_scalar("SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1")
431 .bind(app_id)
432 .fetch_one(&h.db)
433 .await
434 .unwrap();
435 assert_eq!(total, 1, "claim/release/reclaim must net to 1, got {total}");
436 }
437
438 // ── Attack 6: the shipped api_key as a keys-endpoint credential ──
439
440 #[tokio::test]
441 async fn adversarial_api_key_cannot_drive_keys_endpoints() {
442 // The api_key is compiled into every shipped client (`include_str!`), so
443 // anyone with a binary has it. It must not authenticate claim/release/list,
444 // or a stranger could exhaust a per_key app's cap under the app's identity.
445 let mut h = TestHarness::with_mocks().await;
446 let user_id = h.signup("adv_ak", "adv_ak@example.com", "Password1!").await;
447 let (app_id, api_key) = create_draft_app(&h.db, user_id).await;
448 h.client
449 .post_json(&format!("/api/sync/apps/{app_id}/billing/setup"), "")
450 .await;
451 h.client
452 .post_json(
453 &format!("/api/sync/apps/{app_id}/billing/activate"),
454 &json!({ "enforcement_mode": "per_key", "key_cap": 2, "gb_per_key": 1 }).to_string(),
455 )
456 .await;
457
458 // Named as app_secret, but carrying the api_key value: wrong credential.
459 for path in [
460 "/api/sync/keys/claim",
461 "/api/sync/keys/release",
462 "/api/sync/keys/list",
463 ] {
464 let r = h
465 .client
466 .post_json(
467 path,
468 &json!({ "app_secret": api_key, "key": "X" }).to_string(),
469 )
470 .await;
471 assert_eq!(r.status, 401, "{path} accepted the api_key: {}", r.text);
472 }
473
474 // The legacy field name is not a way back in either.
475 let r = h
476 .client
477 .post_json(
478 "/api/sync/keys/claim",
479 &json!({ "api_key": api_key, "key": "X" }).to_string(),
480 )
481 .await;
482 assert_ne!(r.status, 200, "legacy api_key body accepted: {}", r.text);
483
484 // Nothing was claimed by any of the above.
485 let total: i32 =
486 sqlx::query_scalar("SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1")
487 .bind(app_id)
488 .fetch_one(&h.db)
489 .await
490 .unwrap();
491 assert_eq!(total, 0, "rejected calls must not claim a key, got {total}");
492 }
493