Skip to main content

max / makenotwork

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