Skip to main content

max / makenotwork

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