Skip to main content

max / makenotwork

mt: scope verify_quotes to the quoting post's community Close the N3 cross-community matching-oracle: [quote:UUID:HASH] markers verified against get_post_body_markdown by bare id, so a member of one community could confirm text of any live post in another. Replace with get_post_body_markdown_in_community (JOIN categories, filter community_id); there is deliberately no unscoped by-id variant, so the scoping is structural rather than a lint entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 14:45 UTC
Commit: b97e123f627d26eb11373b80c1f4c18bd635f62c
Parent: ed08ae5
5 files changed, +104 insertions, -38 deletions
@@ -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 + }
@@ -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(&quoted_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;