Skip to main content

max / makenotwork

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