Skip to main content

max / makenotwork

17.3 KB · 513 lines History Blame Raw
1 //! Adversarial input validation & edge case tests.
2 //!
3 //! Focus: Input validation & edge cases (Option B from adversarial.md).
4 //! Tests boundary conditions, malformed input, and injection attempts.
5 //! Tests that PASS prove the app correctly validates/rejects bad input.
6 //! Tests that FAIL have found a real bug — flag clearly.
7 //!
8 //! Note: This app returns 422 (Unprocessable Entity) for validation errors,
9 //! which is more precise than 400 (Bad Request).
10
11 use crate::harness::TestHarness;
12 use serde_json::Value;
13
14 /// Helper: sign up a creator with a published project and item.
15 /// Returns (project_id, item_id) with the creator logged in.
16 async fn setup_creator_with_item(h: &mut TestHarness) -> (String, String) {
17 let setup = h.create_creator_with_item("inputtest", "digital", 1000).await;
18 h.publish_project_and_item(&setup.project_id, &setup.item_id).await;
19 (setup.project_id, setup.item_id)
20 }
21
22 // =============================================================================
23 // UUID path parameter handling
24 // =============================================================================
25
26 /// Vulnerability tested: Invalid UUID in path causes server error.
27 /// Non-UUID string should be rejected cleanly (4xx), not panic or 500.
28 #[tokio::test]
29 async fn invalid_uuid_in_path_returns_4xx() {
30 let mut h = TestHarness::new().await;
31 let (_project_id, _item_id) = setup_creator_with_item(&mut h).await;
32
33 // Malformed UUIDs (URI-safe characters only)
34 for bad_id in &["not-a-uuid", "12345", "0000-bad-format"] {
35 let resp = h.client.get(&format!("/api/items/{}/versions", bad_id)).await;
36 assert!(
37 resp.status.is_client_error() || resp.status == 404 || resp.status == 405,
38 "Invalid UUID '{}' should not cause 500, got: {}",
39 bad_id, resp.status
40 );
41 }
42
43 // Nil UUID — valid format but no resource exists
44 let resp = h
45 .client
46 .get("/api/items/00000000-0000-0000-0000-000000000000/versions")
47 .await;
48 assert!(
49 !resp.status.is_server_error(),
50 "Nil UUID should not cause 500, got: {}",
51 resp.status
52 );
53 }
54
55 // =============================================================================
56 // Numeric boundary conditions
57 // =============================================================================
58
59 /// Vulnerability tested: Negative price_cents bypasses validation.
60 /// price_cents should be >= 0 and <= 1,000,000 ($10,000).
61 #[tokio::test]
62 async fn negative_price_cents_rejected() {
63 let mut h = TestHarness::new().await;
64 let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
65
66 let resp = h
67 .client
68 .post_form(
69 &format!("/api/projects/{}/items", project_id),
70 "title=Evil+Item&item_type=digital&price_cents=-100",
71 )
72 .await;
73 assert!(
74 resp.status.is_client_error(),
75 "Negative price_cents should be rejected: {} {}",
76 resp.status, resp.text
77 );
78 }
79
80 /// Vulnerability tested: Overflow price_cents bypasses cap.
81 /// price_cents should be capped at MAX_PRICE_CENTS (1,000,000 = $10,000).
82 #[tokio::test]
83 async fn overflow_price_cents_rejected() {
84 let mut h = TestHarness::new().await;
85 let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
86
87 let resp = h
88 .client
89 .post_form(
90 &format!("/api/projects/{}/items", project_id),
91 "title=Expensive&item_type=digital&price_cents=2000000000",
92 )
93 .await;
94 assert!(
95 resp.status.is_client_error(),
96 "Price > $10,000 should be rejected: {} {}",
97 resp.status, resp.text
98 );
99 }
100
101 /// Vulnerability tested: Zero price_cents accepted for free items.
102 /// This is expected valid behavior — free items should work.
103 #[tokio::test]
104 async fn zero_price_cents_accepted() {
105 let mut h = TestHarness::new().await;
106 let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
107
108 let resp = h
109 .client
110 .post_form(
111 &format!("/api/projects/{}/items", project_id),
112 "title=Free+Item&item_type=digital&price_cents=0",
113 )
114 .await;
115 assert!(
116 resp.status.is_success(),
117 "Zero price (free item) should be accepted: {} {}",
118 resp.status, resp.text
119 );
120 }
121
122 // =============================================================================
123 // String length boundaries
124 // =============================================================================
125
126 /// Vulnerability tested: Oversized item title bypasses length check.
127 /// Item title limit is 200 chars.
128 #[tokio::test]
129 async fn item_title_too_long_rejected() {
130 let mut h = TestHarness::new().await;
131 let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
132
133 let long_title = "A".repeat(201);
134 let resp = h
135 .client
136 .post_form(
137 &format!("/api/projects/{}/items", project_id),
138 &format!("title={}&item_type=digital", long_title),
139 )
140 .await;
141 assert!(
142 resp.status.is_client_error(),
143 "201-char title should be rejected: {} {}",
144 resp.status, resp.text
145 );
146 }
147
148 /// Vulnerability tested: Oversized item description bypasses length check.
149 /// Item description limit is 5000 chars.
150 #[tokio::test]
151 async fn item_description_too_long_rejected() {
152 let mut h = TestHarness::new().await;
153 let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
154
155 let long_desc = "B".repeat(5001);
156 let resp = h
157 .client
158 .post_form(
159 &format!("/api/projects/{}/items", project_id),
160 &format!("title=Normal&item_type=digital&description={}", long_desc),
161 )
162 .await;
163 assert!(
164 resp.status.is_client_error(),
165 "5001-char description should be rejected: {} {}",
166 resp.status, resp.text
167 );
168 }
169
170 /// Vulnerability tested: Empty required title accepted.
171 /// Item title is required (min 1 char).
172 #[tokio::test]
173 async fn empty_item_title_rejected() {
174 let mut h = TestHarness::new().await;
175 let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
176
177 let resp = h
178 .client
179 .post_form(
180 &format!("/api/projects/{}/items", project_id),
181 "title=&item_type=digital",
182 )
183 .await;
184 assert!(
185 resp.status.is_client_error(),
186 "Empty title should be rejected: {} {}",
187 resp.status, resp.text
188 );
189 }
190
191 // =============================================================================
192 // Injection attempts
193 // =============================================================================
194
195 /// Vulnerability tested: XSS payload in item title causes stored XSS.
196 /// App should accept the text (it's valid content) but store it safely.
197 /// Askama templates auto-escape by default, so the real defense is at render time.
198 /// This test verifies the API round-trips the value without mangling it.
199 #[tokio::test]
200 async fn xss_in_title_stored_safely() {
201 let mut h = TestHarness::new().await;
202 let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
203
204 let xss_title = "<script>alert('xss')</script>";
205 let resp = h
206 .client
207 .post_form(
208 &format!("/api/projects/{}/items", project_id),
209 &format!("title={}&item_type=digital", xss_title),
210 )
211 .await;
212 assert!(
213 resp.status.is_success(),
214 "XSS title should be accepted as content: {} {}",
215 resp.status, resp.text
216 );
217 let item: Value = resp.json();
218 assert_eq!(
219 item["title"].as_str().unwrap(),
220 xss_title,
221 "Title should be stored verbatim (escaping happens at render time)"
222 );
223 }
224
225 /// Vulnerability tested: SQL injection in title causes data leak or mutation.
226 /// All queries use parameterized statements (sqlx), so injection should be impossible.
227 #[tokio::test]
228 async fn sql_injection_in_title_harmless() {
229 let mut h = TestHarness::new().await;
230 let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
231
232 let sqli_title = "'; DROP TABLE items; --";
233 let resp = h
234 .client
235 .post_form(
236 &format!("/api/projects/{}/items", project_id),
237 &format!("title={}&item_type=digital", sqli_title),
238 )
239 .await;
240 assert!(
241 resp.status.is_success(),
242 "SQL injection payload should be stored as literal text: {} {}",
243 resp.status, resp.text
244 );
245 let item: Value = resp.json();
246 assert_eq!(
247 item["title"].as_str().unwrap(),
248 sqli_title,
249 "SQL injection payload should be stored verbatim"
250 );
251
252 // Verify the items table still works (not dropped)
253 let resp = h.client.get("/api/projects").await;
254 assert!(resp.status.is_success(), "Projects list should still work after SQL injection attempt");
255 }
256
257 // =============================================================================
258 // Duplicate creation
259 // =============================================================================
260
261 /// Vulnerability tested: Duplicate project slug creates confusion or overwrites.
262 /// Second project with same slug should be rejected (unique constraint).
263 #[tokio::test]
264 async fn duplicate_project_slug_rejected() {
265 let mut h = TestHarness::new().await;
266 let (_project_id, _item_id) = setup_creator_with_item(&mut h).await;
267
268 // Try creating another project with the same slug
269 let resp = h
270 .client
271 .post_form("/api/projects", "slug=inputtest-proj&title=Duplicate+Shop")
272 .await;
273 assert!(
274 !resp.status.is_success(),
275 "Duplicate slug must not succeed: {} {}",
276 resp.status, resp.text
277 );
278 }
279
280 /// Vulnerability tested: Duplicate username on signup.
281 /// Second signup with same username should be rejected.
282 #[tokio::test]
283 async fn duplicate_username_rejected() {
284 let mut h = TestHarness::new().await;
285 let _user_id = h.signup("dupuser", "dup1@test.com", "password123").await;
286 h.client.post_form("/logout", "").await;
287
288 // Try signing up again with the same username but different email
289 let resp = h
290 .client
291 .post_form(
292 "/join",
293 "username=dupuser&email=dup2@test.com&password=password456&confirm_password=password456",
294 )
295 .await;
296 // Should fail — check we get an error, not a second account
297 // The response could be a redirect back to join with error or a 400/409
298 let is_error = !resp.status.is_success()
299 || resp.text.contains("taken")
300 || resp.text.contains("already")
301 || resp.text.contains("exists")
302 || resp.status.is_redirection();
303 assert!(
304 is_error,
305 "Duplicate username should be rejected: {} {}",
306 resp.status, resp.text
307 );
308 }
309
310 // =============================================================================
311 // Slug boundary values
312 // =============================================================================
313
314 /// Vulnerability tested: Slug boundary values — min/max length enforcement.
315 /// Slug requires 2-100 chars, alphanumeric + hyphen only.
316 #[tokio::test]
317 async fn slug_boundary_values() {
318 let mut h = TestHarness::new().await;
319 let user_id = h.signup("slugtest", "slugtest@test.com", "password123").await;
320 h.grant_creator(user_id).await;
321 h.client.post_form("/logout", "").await;
322 h.login("slugtest", "password123").await;
323
324 // 1-char slug — should be rejected (min 2)
325 let resp = h
326 .client
327 .post_form("/api/projects", "slug=a&title=One+Char")
328 .await;
329 assert!(
330 !resp.status.is_success(),
331 "1-char slug should be rejected: {} {}",
332 resp.status, resp.text
333 );
334
335 // 2-char slug — should be accepted (boundary)
336 let resp = h
337 .client
338 .post_form("/api/projects", "slug=ab&title=Two+Char")
339 .await;
340 assert!(
341 resp.status.is_success(),
342 "2-char slug should be accepted: {} {}",
343 resp.status, resp.text
344 );
345
346 // 100-char slug — should be accepted (boundary)
347 let slug_100 = "a".repeat(100);
348 let resp = h
349 .client
350 .post_form(
351 "/api/projects",
352 &format!("slug={}&title=Max+Slug", slug_100),
353 )
354 .await;
355 assert!(
356 resp.status.is_success(),
357 "100-char slug should be accepted: {} {}",
358 resp.status, resp.text
359 );
360
361 // 101-char slug — should be rejected (over max)
362 let slug_101 = "b".repeat(101);
363 let resp = h
364 .client
365 .post_form(
366 "/api/projects",
367 &format!("slug={}&title=Over+Max", slug_101),
368 )
369 .await;
370 assert!(
371 !resp.status.is_success(),
372 "101-char slug should be rejected: {} {}",
373 resp.status, resp.text
374 );
375
376 // Slug with special characters — should be rejected
377 let resp = h
378 .client
379 .post_form("/api/projects", "slug=my_shop!&title=Special+Chars")
380 .await;
381 assert!(
382 !resp.status.is_success(),
383 "Slug with special chars should be rejected: {} {}",
384 resp.status, resp.text
385 );
386 }
387
388 // =============================================================================
389 // Unicode handling
390 // =============================================================================
391
392 /// Vulnerability tested: Multibyte characters bypass length limits.
393 /// Length checks should count chars, not bytes. 200 CJK characters = 200 chars
394 /// (within 200-char limit) but 600 bytes.
395 #[tokio::test]
396 async fn unicode_chars_counted_not_bytes() {
397 let mut h = TestHarness::new().await;
398 let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
399
400 // 200 CJK characters — should be accepted (within 200-char limit)
401 let cjk_200: String = "\u{4e00}".repeat(200);
402 let resp = h
403 .client
404 .post_form(
405 &format!("/api/projects/{}/items", project_id),
406 &format!("title={}&item_type=digital", cjk_200),
407 )
408 .await;
409 assert!(
410 resp.status.is_success(),
411 "200 CJK chars should be accepted (chars.count() not bytes): {} {}",
412 resp.status, resp.text
413 );
414
415 // 201 CJK characters — should be rejected
416 let cjk_201: String = "\u{4e00}".repeat(201);
417 let resp = h
418 .client
419 .post_form(
420 &format!("/api/projects/{}/items", project_id),
421 &format!("title={}&item_type=digital", cjk_201),
422 )
423 .await;
424 assert!(
425 resp.status.is_client_error(),
426 "201 CJK chars should be rejected: {} {}",
427 resp.status, resp.text
428 );
429 }
430
431 // =============================================================================
432 // Validation boundaries — these gaps have been fixed
433 // =============================================================================
434
435 /// PWYW minimum price rejects negative values.
436 #[tokio::test]
437 async fn pwyw_min_cents_negative_rejected() {
438 let mut h = TestHarness::new().await;
439 let (_project_id, item_id) = setup_creator_with_item(&mut h).await;
440
441 let resp = h
442 .client
443 .put_form(
444 &format!("/api/items/{}", item_id),
445 "pwyw_enabled=on&pwyw_min_cents=-500",
446 )
447 .await;
448 assert!(
449 resp.status.is_client_error(),
450 "Negative pwyw_min_cents should be rejected: {} {}",
451 resp.status, resp.text
452 );
453 }
454
455 /// Fixed discount value is capped at MAX_PRICE_CENTS.
456 #[tokio::test]
457 async fn fixed_discount_upper_bound_enforced() {
458 let mut h = TestHarness::new().await;
459 let (_project_id, _item_id) = setup_creator_with_item(&mut h).await;
460
461 let resp = h
462 .client
463 .post_form(
464 "/api/promo-codes",
465 "code=BIGDISCOUNT&code_purpose=discount&discount_type=fixed&discount_value=99999999",
466 )
467 .await;
468 assert!(
469 resp.status.is_client_error(),
470 "Fixed discount above MAX_PRICE_CENTS should be rejected: {} {}",
471 resp.status, resp.text
472 );
473 }
474
475 /// Vulnerability tested: Link URL with javascript: scheme should be rejected.
476 /// Verifies that protocol validation prevents XSS via custom links.
477 #[tokio::test]
478 async fn link_javascript_scheme_rejected() {
479 let mut h = TestHarness::new().await;
480 let user_id = h.signup("linktest", "linktest@test.com", "password123").await;
481 h.grant_creator(user_id).await;
482 h.client.post_form("/logout", "").await;
483 h.login("linktest", "password123").await;
484
485 // javascript: scheme — classic XSS vector
486 let resp = h
487 .client
488 .post_form(
489 "/api/links",
490 "title=Evil+Link&url=javascript:alert(document.cookie)",
491 )
492 .await;
493 assert!(
494 !resp.status.is_success(),
495 "javascript: scheme should be rejected: {} {}",
496 resp.status, resp.text
497 );
498
499 // data: scheme — another XSS vector
500 let resp = h
501 .client
502 .post_form(
503 "/api/links",
504 "title=Data+Link&url=data:text/html,<script>alert(1)</script>",
505 )
506 .await;
507 assert!(
508 !resp.status.is_success(),
509 "data: scheme should be rejected: {} {}",
510 resp.status, resp.text
511 );
512 }
513