max / makenotwork
5 files changed,
+104 insertions,
-38 deletions
A
multithreaded/.sqlx/query-88156f7230531c0aaa7ffdac3e70b0fc2655dfb176d446b93723a252ada349b0.json
+29
| @@ -0,0 +1,29 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n JOIN categories c ON c.id = t.category_id\n WHERE p.id = $1 AND c.community_id = $2\n AND p.removed_at IS NULL AND t.deleted_at IS NULL", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "author_id", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "body_markdown", | |
| 14 | + | "type_info": "Text" | |
| 15 | + | } | |
| 16 | + | ], | |
| 17 | + | "parameters": { | |
| 18 | + | "Left": [ | |
| 19 | + | "Uuid", | |
| 20 | + | "Uuid" | |
| 21 | + | ] | |
| 22 | + | }, | |
| 23 | + | "nullable": [ | |
| 24 | + | false, | |
| 25 | + | false | |
| 26 | + | ] | |
| 27 | + | }, | |
| 28 | + | "hash": "88156f7230531c0aaa7ffdac3e70b0fc2655dfb176d446b93723a252ada349b0" | |
| 29 | + | } |
D
multithreaded/.sqlx/query-947028eba046c3be1515a1a0adc8102bd343d5ec539a18aa296f53c508d5e3e2.json
-28
| @@ -1,28 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "db_name": "PostgreSQL", | |
| 3 | - | "query": "SELECT p.author_id, p.body_markdown\n FROM posts p\n JOIN threads t ON t.id = p.thread_id\n WHERE p.id = $1 AND p.removed_at IS NULL AND t.deleted_at IS NULL", | |
| 4 | - | "describe": { | |
| 5 | - | "columns": [ | |
| 6 | - | { | |
| 7 | - | "ordinal": 0, | |
| 8 | - | "name": "author_id", | |
| 9 | - | "type_info": "Uuid" | |
| 10 | - | }, | |
| 11 | - | { | |
| 12 | - | "ordinal": 1, | |
| 13 | - | "name": "body_markdown", | |
| 14 | - | "type_info": "Text" | |
| 15 | - | } | |
| 16 | - | ], | |
| 17 | - | "parameters": { | |
| 18 | - | "Left": [ | |
| 19 | - | "Uuid" | |
| 20 | - | ] | |
| 21 | - | }, | |
| 22 | - | "nullable": [ | |
| 23 | - | false, | |
| 24 | - | false | |
| 25 | - | ] | |
| 26 | - | }, | |
| 27 | - | "hash": "947028eba046c3be1515a1a0adc8102bd343d5ec539a18aa296f53c508d5e3e2" | |
| 28 | - | } |
| @@ -763,11 +763,20 @@ pub async fn count_footnotes_for_post( | |||
| 763 | 763 | .await | |
| 764 | 764 | } | |
| 765 | 765 | ||
| 766 | - | /// Fetch a post's author_id and body_markdown for quote verification. | |
| 767 | - | #[tracing::instrument(skip_all)] | |
| 768 | - | pub async fn get_post_body_markdown( | |
| 766 | + | /// Fetch a post's author_id and body_markdown for quote verification, scoped to | |
| 767 | + | /// the quoting post's community. | |
| 768 | + | /// | |
| 769 | + | /// The `community_id` filter is not optional: a `[quote:UUID:HASH]` marker only | |
| 770 | + | /// verifies against a post *in the same community*, so a member of community A | |
| 771 | + | /// can never use quote verification as an oracle against a post in community B | |
| 772 | + | /// (C1 scope class, N3). There is deliberately no unscoped `by-id` variant of | |
| 773 | + | /// this loader — the scoping is structural, which is why it needs no | |
| 774 | + | /// `clippy.toml` disallowed-methods entry (cf. `get_category_in_community`). | |
| 775 | + | #[tracing::instrument(skip_all)] | |
| 776 | + | pub async fn get_post_body_markdown_in_community( | |
| 769 | 777 | pool: &PgPool, | |
| 770 | 778 | post_id: Uuid, | |
| 779 | + | community_id: Uuid, | |
| 771 | 780 | ) -> Result<Option<(Uuid, String)>, sqlx::Error> { | |
| 772 | 781 | // Only live posts can be quoted: a mod-removed post (or one in a soft-deleted | |
| 773 | 782 | // thread) must fail quote verification so its text cannot be re-surfaced into | |
| @@ -776,8 +785,11 @@ pub async fn get_post_body_markdown( | |||
| 776 | 785 | "SELECT p.author_id, p.body_markdown | |
| 777 | 786 | FROM posts p | |
| 778 | 787 | JOIN threads t ON t.id = p.thread_id | |
| 779 | - | WHERE p.id = $1 AND p.removed_at IS NULL AND t.deleted_at IS NULL", | |
| 788 | + | JOIN categories c ON c.id = t.category_id | |
| 789 | + | WHERE p.id = $1 AND c.community_id = $2 | |
| 790 | + | AND p.removed_at IS NULL AND t.deleted_at IS NULL", | |
| 780 | 791 | post_id, | |
| 792 | + | community_id, | |
| 781 | 793 | ) | |
| 782 | 794 | .fetch_optional(pool) | |
| 783 | 795 | .await |
| @@ -96,6 +96,7 @@ pub(super) fn compute_quote_hash(text: &str) -> String { | |||
| 96 | 96 | #[tracing::instrument(skip_all)] | |
| 97 | 97 | pub(super) async fn verify_quotes( | |
| 98 | 98 | db: &sqlx::PgPool, | |
| 99 | + | community_id: Uuid, | |
| 99 | 100 | body: &str, | |
| 100 | 101 | ) -> Result<Vec<Uuid>, Response> { | |
| 101 | 102 | let refs = find_quote_refs(body); | |
| @@ -118,10 +119,11 @@ pub(super) async fn verify_quotes( | |||
| 118 | 119 | return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response()); | |
| 119 | 120 | } | |
| 120 | 121 | ||
| 121 | - | let (_, original_markdown) = mt_db::queries::get_post_body_markdown(db, post_id) | |
| 122 | - | .await | |
| 123 | - | .map_err(db_error)? | |
| 124 | - | .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?; | |
| 122 | + | let (_, original_markdown) = | |
| 123 | + | mt_db::queries::get_post_body_markdown_in_community(db, post_id, community_id) | |
| 124 | + | .await | |
| 125 | + | .map_err(db_error)? | |
| 126 | + | .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?; | |
| 125 | 127 | ||
| 126 | 128 | if !original_markdown.contains("ed_text) { | |
| 127 | 129 | return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.").into_response()); | |
| @@ -261,7 +263,7 @@ pub(in crate::routes) async fn create_thread_handler( | |||
| 261 | 263 | .map_err(db_error)? | |
| 262 | 264 | .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?; | |
| 263 | 265 | ||
| 264 | - | verify_quotes(&state.db, body).await?; | |
| 266 | + | verify_quotes(&state.db, community.id, body).await?; | |
| 265 | 267 | ||
| 266 | 268 | let (body_html, mention_ids) = resolve_and_render_mentions( | |
| 267 | 269 | &state.db, body, community.id, &slug, user.user_id, author_plus, | |
| @@ -329,7 +331,7 @@ pub(in crate::routes) async fn create_reply_handler( | |||
| 329 | 331 | reject_embeds_for_free_user(body)?; | |
| 330 | 332 | } | |
| 331 | 333 | ||
| 332 | - | verify_quotes(&state.db, body).await?; | |
| 334 | + | verify_quotes(&state.db, community.id, body).await?; | |
| 333 | 335 | ||
| 334 | 336 | let (body_html, mention_ids) = resolve_and_render_mentions( | |
| 335 | 337 | &state.db, body, community.id, &slug, user.user_id, author_plus, |
| @@ -552,6 +552,57 @@ async fn valid_quote_accepted() { | |||
| 552 | 552 | ); | |
| 553 | 553 | } | |
| 554 | 554 | ||
| 555 | + | /// Regression (audit_review.md N3 — verify_quotes scope gap): a `[quote:UUID:HASH]` | |
| 556 | + | /// marker must only verify against a post *in the same community*. Quoting a post | |
| 557 | + | /// that lives in another community — even with the exact text and correct hash — | |
| 558 | + | /// must be rejected, closing the cross-community matching-oracle. | |
| 559 | + | #[tokio::test] | |
| 560 | + | async fn cross_community_quote_rejected() { | |
| 561 | + | let mut h = TestHarness::new().await; | |
| 562 | + | let user_id = h.login_as("crosscommquoter").await; | |
| 563 | + | ||
| 564 | + | // Community Beta holds the target post. | |
| 565 | + | let comm_b = h.create_community("Beta", "beta").await; | |
| 566 | + | let cat_b = h.create_category(comm_b, "General", "general").await; | |
| 567 | + | h.add_membership(user_id, comm_b, "member").await; | |
| 568 | + | let secret_text = "Secret content only in community beta"; | |
| 569 | + | let thread_b = h | |
| 570 | + | .create_thread_with_post(cat_b, user_id, "Beta Thread", secret_text) | |
| 571 | + | .await; | |
| 572 | + | let post_b = mt_db::queries::list_posts_in_thread(&h.db, thread_b).await.unwrap()[0].id; | |
| 573 | + | ||
| 574 | + | // Community Alpha is where the reply is composed. | |
| 575 | + | let comm_a = h.create_community("Alpha", "alpha").await; | |
| 576 | + | let cat_a = h.create_category(comm_a, "General", "general").await; | |
| 577 | + | h.add_membership(user_id, comm_a, "member").await; | |
| 578 | + | let thread_a = h | |
| 579 | + | .create_thread_with_post(cat_a, user_id, "Alpha Thread", "alpha op content") | |
| 580 | + | .await; | |
| 581 | + | ||
| 582 | + | // Quote Beta's post (exact text + correct hash) from inside Alpha. | |
| 583 | + | let hash = compute_quote_hash(secret_text); | |
| 584 | + | let body = format!( | |
| 585 | + | "> {}\n[quote:{}:{}]\n\nQuoting across communities", | |
| 586 | + | secret_text, post_b, hash | |
| 587 | + | ); | |
| 588 | + | let encoded_body = urlencoding::encode(&body); | |
| 589 | + | ||
| 590 | + | let thread_url = format!("/p/alpha/general/{}", thread_a); | |
| 591 | + | h.client.get(&thread_url).await; | |
| 592 | + | let reply_url = format!("/p/alpha/general/{}/reply", thread_a); | |
| 593 | + | let resp = h | |
| 594 | + | .client | |
| 595 | + | .post_form(&reply_url, &format!("body={}", encoded_body)) | |
| 596 | + | .await; | |
| 597 | + | ||
| 598 | + | assert_eq!( | |
| 599 | + | resp.status.as_u16(), | |
| 600 | + | 422, | |
| 601 | + | "quoting a post from another community must be rejected (N3 scope seal), got {}", | |
| 602 | + | resp.status | |
| 603 | + | ); | |
| 604 | + | } | |
| 605 | + | ||
| 555 | 606 | #[tokio::test] | |
| 556 | 607 | async fn fabricated_quote_rejected() { | |
| 557 | 608 | let mut h = TestHarness::new().await; |