Skip to main content

max / makenotwork

13.7 KB · 445 lines History Blame Raw
1 //! Adversarial IDOR & authorization tests.
2 //!
3 //! Focus: Authorization & IDOR (Option A from adversarial.md).
4 //! Each test attempts to access, modify, or delete resources belonging to
5 //! another user. Tests that PASS prove the app correctly rejects the attack.
6
7 use crate::harness::TestHarness;
8 use makenotwork::db;
9 use serde_json::Value;
10
11 /// Helper: create a "victim" creator with a published project, published item,
12 /// and an unpublished blog post. Returns (victim_id, project_id, item_id, blog_post_id).
13 /// Logs out when done.
14 async fn setup_victim(h: &mut TestHarness) -> (db::UserId, String, String, String) {
15 let victim_id = h.signup("victim", "victim@test.com", "password123").await;
16 h.grant_creator(victim_id).await;
17 h.client.post_form("/logout", "").await;
18 h.login("victim", "password123").await;
19
20 // Create project
21 let resp = h
22 .client
23 .post_form("/api/projects", "slug=victim-shop&title=Victim+Shop")
24 .await;
25 assert!(
26 resp.status.is_success(),
27 "Create project failed: {}",
28 resp.text
29 );
30 let project: Value = resp.json();
31 let project_id = project["id"].as_str().unwrap().to_string();
32
33 // Create item
34 let resp = h
35 .client
36 .post_form(
37 &format!("/api/projects/{project_id}/items"),
38 "title=Secret+Item&item_type=digital&price_cents=1000",
39 )
40 .await;
41 assert!(
42 resp.status.is_success(),
43 "Create item failed: {}",
44 resp.text
45 );
46 let item: Value = resp.json();
47 let item_id = item["id"].as_str().unwrap().to_string();
48
49 // Publish both
50 h.client
51 .put_json(
52 &format!("/api/projects/{project_id}"),
53 r#"{"is_public": true}"#,
54 )
55 .await;
56 h.client
57 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
58 .await;
59
60 // Create blog post (unpublished, should not be visible to attacker)
61 let resp = h
62 .client
63 .post_json(
64 &format!("/api/projects/{project_id}/blog"),
65 r#"{"title": "Draft Post"}"#,
66 )
67 .await;
68 assert!(
69 resp.status.is_success(),
70 "Create blog post failed: {}",
71 resp.text
72 );
73 let post: Value = resp.json();
74 let post_id = post["id"].as_str().unwrap().to_string();
75
76 h.client.post_form("/logout", "").await;
77 (victim_id, project_id, item_id, post_id)
78 }
79
80 /// Helper: sign up an "attacker" creator and log in.
81 /// Returns attacker's user_id.
82 async fn setup_attacker(h: &mut TestHarness) -> db::UserId {
83 let attacker_id = h
84 .signup("attacker", "attacker@test.com", "password123")
85 .await;
86 h.grant_creator(attacker_id).await;
87 h.client.post_form("/logout", "").await;
88 h.login("attacker", "password123").await;
89 attacker_id
90 }
91
92 // Project IDOR
93
94 /// Vulnerability tested: IDOR on project update.
95 /// Attacker knows victim's project UUID and tries to rename it.
96 #[tokio::test]
97 async fn project_update_by_non_owner() {
98 let mut h = TestHarness::new().await;
99 let (_victim_id, project_id, _item_id, _post_id) = setup_victim(&mut h).await;
100 let _attacker_id = setup_attacker(&mut h).await;
101
102 let resp = h
103 .client
104 .put_json(
105 &format!("/api/projects/{project_id}"),
106 r#"{"title": "Pwned"}"#,
107 )
108 .await;
109 assert_eq!(
110 resp.status, 403,
111 "Non-owner should not update another user's project: {} {}",
112 resp.status, resp.text
113 );
114 }
115
116 /// Vulnerability tested: IDOR on project deletion.
117 /// Attacker tries to delete victim's project.
118 #[tokio::test]
119 async fn project_delete_by_non_owner() {
120 let mut h = TestHarness::new().await;
121 let (_victim_id, project_id, _item_id, _post_id) = setup_victim(&mut h).await;
122 let _attacker_id = setup_attacker(&mut h).await;
123
124 let resp = h
125 .client
126 .delete(&format!("/api/projects/{project_id}"))
127 .await;
128 assert_eq!(
129 resp.status, 403,
130 "Non-owner should not delete another user's project: {} {}",
131 resp.status, resp.text
132 );
133
134 // Verify project still exists by logging back in as victim
135 h.client.post_form("/logout", "").await;
136 h.login("victim", "password123").await;
137 let resp = h.client.get("/api/projects").await;
138 let list: Value = resp.json();
139 let data = list["data"].as_array().unwrap();
140 assert_eq!(data.len(), 1, "Victim's project should still exist");
141 }
142
143 // Item IDOR
144
145 /// Vulnerability tested: IDOR on item creation.
146 /// Attacker injects an item into victim's project.
147 #[tokio::test]
148 async fn item_create_in_others_project() {
149 let mut h = TestHarness::new().await;
150 let (_victim_id, project_id, _item_id, _post_id) = setup_victim(&mut h).await;
151 let _attacker_id = setup_attacker(&mut h).await;
152
153 let resp = h
154 .client
155 .post_form(
156 &format!("/api/projects/{project_id}/items"),
157 "title=Injected&item_type=digital&price_cents=0",
158 )
159 .await;
160 assert_eq!(
161 resp.status, 403,
162 "Non-owner should not create items in another user's project: {} {}",
163 resp.status, resp.text
164 );
165 }
166
167 /// Vulnerability tested: IDOR on item update.
168 /// Attacker tries to change victim's item price to $0.
169 #[tokio::test]
170 async fn item_update_by_non_owner() {
171 let mut h = TestHarness::new().await;
172 let (_victim_id, _project_id, item_id, _post_id) = setup_victim(&mut h).await;
173 let _attacker_id = setup_attacker(&mut h).await;
174
175 let resp = h
176 .client
177 .put_form(
178 &format!("/api/items/{item_id}"),
179 "price_cents=0&title=Free+Now",
180 )
181 .await;
182 assert_eq!(
183 resp.status, 403,
184 "Non-owner should not update another user's item: {} {}",
185 resp.status, resp.text
186 );
187 }
188
189 /// Vulnerability tested: IDOR on item deletion.
190 /// Attacker tries to delete victim's item.
191 #[tokio::test]
192 async fn item_delete_by_non_owner() {
193 let mut h = TestHarness::new().await;
194 let (_victim_id, _project_id, item_id, _post_id) = setup_victim(&mut h).await;
195 let _attacker_id = setup_attacker(&mut h).await;
196
197 let resp = h.client.delete(&format!("/api/items/{item_id}")).await;
198 assert_eq!(
199 resp.status, 403,
200 "Non-owner should not delete another user's item: {} {}",
201 resp.status, resp.text
202 );
203 }
204
205 /// Vulnerability tested: IDOR on item duplication.
206 /// Attacker tries to clone victim's item into attacker's project.
207 #[tokio::test]
208 async fn item_duplicate_by_non_owner() {
209 let mut h = TestHarness::new().await;
210 let (_victim_id, _project_id, item_id, _post_id) = setup_victim(&mut h).await;
211 let _attacker_id = setup_attacker(&mut h).await;
212
213 let resp = h
214 .client
215 .post_form(&format!("/api/items/{item_id}/duplicate"), "")
216 .await;
217 assert_eq!(
218 resp.status, 403,
219 "Non-owner should not duplicate another user's item: {} {}",
220 resp.status, resp.text
221 );
222 }
223
224 /// Vulnerability tested: IDOR on item tag manipulation.
225 /// Attacker tries to add a tag to victim's item.
226 #[tokio::test]
227 async fn item_tags_by_non_owner() {
228 let mut h = TestHarness::new().await;
229 let (_victim_id, _project_id, item_id, _post_id) = setup_victim(&mut h).await;
230 let _attacker_id = setup_attacker(&mut h).await;
231
232 let fake_tag_id = uuid::Uuid::new_v4();
233 let resp = h
234 .client
235 .post_form(
236 &format!("/api/items/{item_id}/tags"),
237 &format!("tag_id={fake_tag_id}"),
238 )
239 .await;
240 assert_eq!(
241 resp.status, 403,
242 "Non-owner should not add tags to another user's item: {} {}",
243 resp.status, resp.text
244 );
245 }
246
247 // Blog post IDOR
248
249 /// Vulnerability tested: IDOR on blog post read (edit endpoint).
250 /// Attacker tries to read victim's unpublished draft via the edit API.
251 #[tokio::test]
252 async fn blog_read_by_non_owner() {
253 let mut h = TestHarness::new().await;
254 let (_victim_id, _project_id, _item_id, post_id) = setup_victim(&mut h).await;
255 let _attacker_id = setup_attacker(&mut h).await;
256
257 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
258 assert_eq!(
259 resp.status, 403,
260 "Non-owner should not read another user's blog post via edit API: {} {}",
261 resp.status, resp.text
262 );
263 }
264
265 /// Vulnerability tested: IDOR on blog post update.
266 /// Attacker tries to overwrite victim's blog post content.
267 #[tokio::test]
268 async fn blog_update_by_non_owner() {
269 let mut h = TestHarness::new().await;
270 let (_victim_id, _project_id, _item_id, post_id) = setup_victim(&mut h).await;
271 let _attacker_id = setup_attacker(&mut h).await;
272
273 let resp = h
274 .client
275 .put_json(
276 &format!("/api/blog/{post_id}"),
277 r#"{"title": "Defaced", "slug": "defaced", "body_markdown": "You got hacked", "is_published": true}"#,
278 )
279 .await;
280 assert_eq!(
281 resp.status, 403,
282 "Non-owner should not update another user's blog post: {} {}",
283 resp.status, resp.text
284 );
285 }
286
287 /// Vulnerability tested: IDOR on blog post deletion.
288 /// Attacker tries to delete victim's blog post.
289 #[tokio::test]
290 async fn blog_delete_by_non_owner() {
291 let mut h = TestHarness::new().await;
292 let (_victim_id, _project_id, _item_id, post_id) = setup_victim(&mut h).await;
293 let _attacker_id = setup_attacker(&mut h).await;
294
295 let resp = h.client.delete(&format!("/api/blog/{post_id}")).await;
296 assert_eq!(
297 resp.status, 403,
298 "Non-owner should not delete another user's blog post: {} {}",
299 resp.status, resp.text
300 );
301 }
302
303 // Permission boundary tests
304
305 /// Vulnerability tested: Non-creator bypasses creator gate.
306 /// Regular user (no creator permission) tries to create a project.
307 #[tokio::test]
308 async fn non_creator_create_project() {
309 let mut h = TestHarness::new().await;
310 // Sign up but do NOT grant creator
311 let _user_id = h.signup("normie", "normie@test.com", "password123").await;
312
313 let resp = h
314 .client
315 .post_form("/api/projects", "slug=my-shop&title=My+Shop")
316 .await;
317 assert_eq!(
318 resp.status, 403,
319 "Non-creator should not be able to create projects: {} {}",
320 resp.status, resp.text
321 );
322 }
323
324 /// Vulnerability tested: Suspended creator bypasses suspension check.
325 /// Creator is suspended, then tries to update their own project.
326 #[tokio::test]
327 async fn suspended_creator_blocked_from_writes() {
328 let mut h = TestHarness::new().await;
329 let user_id = h
330 .signup("suspended", "suspended@test.com", "password123")
331 .await;
332 h.grant_creator(user_id).await;
333 h.client.post_form("/logout", "").await;
334 h.login("suspended", "password123").await;
335
336 // Create project while not suspended
337 let resp = h
338 .client
339 .post_form("/api/projects", "slug=my-project&title=My+Project")
340 .await;
341 assert!(
342 resp.status.is_success(),
343 "Project creation should work: {}",
344 resp.text
345 );
346 let project: Value = resp.json();
347 let project_id = project["id"].as_str().unwrap();
348
349 // Create item
350 let resp = h
351 .client
352 .post_form(
353 &format!("/api/projects/{project_id}/items"),
354 "title=My+Item&item_type=digital&price_cents=500",
355 )
356 .await;
357 assert!(
358 resp.status.is_success(),
359 "Item creation should work: {}",
360 resp.text
361 );
362 let item: Value = resp.json();
363 let item_id = item["id"].as_str().unwrap();
364
365 // Suspend the user via direct DB
366 db::users::suspend_user(&h.db, user_id, "test suspension")
367 .await
368 .unwrap();
369
370 // Re-login to pick up suspended state
371 h.client.post_form("/logout", "").await;
372 h.login("suspended", "password123").await;
373
374 // Try to update project, should be blocked
375 let resp = h
376 .client
377 .put_json(
378 &format!("/api/projects/{project_id}"),
379 r#"{"title": "Updated While Suspended"}"#,
380 )
381 .await;
382 assert_eq!(
383 resp.status, 403,
384 "Suspended user should not update projects: {} {}",
385 resp.status, resp.text
386 );
387
388 // Try to update item, should be blocked
389 let resp = h
390 .client
391 .put_form(
392 &format!("/api/items/{item_id}"),
393 "title=Updated+While+Suspended",
394 )
395 .await;
396 assert_eq!(
397 resp.status, 403,
398 "Suspended user should not update items: {} {}",
399 resp.status, resp.text
400 );
401
402 // Try to create new project, should be blocked
403 let resp = h
404 .client
405 .post_form("/api/projects", "slug=new-project&title=New+Project")
406 .await;
407 assert_eq!(
408 resp.status, 403,
409 "Suspended user should not create projects: {} {}",
410 resp.status, resp.text
411 );
412 }
413
414 // Enumeration / information leakage
415
416 /// Vulnerability tested: Resource enumeration via list endpoints.
417 /// Attacker lists their own projects/items, victim's resources must not appear.
418 #[tokio::test]
419 async fn victim_resources_invisible_in_attacker_listing() {
420 let mut h = TestHarness::new().await;
421 let (_victim_id, _project_id, _item_id, _post_id) = setup_victim(&mut h).await;
422 let _attacker_id = setup_attacker(&mut h).await;
423
424 // List attacker's projects, should be empty (attacker has none)
425 let resp = h.client.get("/api/projects").await;
426 assert!(resp.status.is_success());
427 let list: Value = resp.json();
428 let data = list["data"].as_array().unwrap();
429 assert!(
430 data.is_empty(),
431 "Attacker's project list should not contain victim's projects, got {} items",
432 data.len()
433 );
434
435 // List attacker's promo codes, should be empty
436 let resp = h.client.get("/api/promo-codes").await;
437 assert!(resp.status.is_success());
438 let list: Value = resp.json();
439 let data = list["data"].as_array().unwrap();
440 assert!(
441 data.is_empty(),
442 "Attacker's promo code list should be empty"
443 );
444 }
445