Barazo AppView backend barazo.forum

Merge pull request #65 from barazo-forum/fix/search-topics-security-gaps

fix(security): add community_did filter, reply deletion filter, and maturity check

authored by

Guido X Jansen and committed by
GitHub
9ea51e7d 7a4bb9cc

+223
+33
src/routes/search.ts
··· 1 1 import { sql } from 'drizzle-orm' 2 2 import type { FastifyPluginCallback } from 'fastify' 3 + import { getCommunityDid } from '../config/env.js' 3 4 import { badRequest } from '../lib/api-errors.js' 4 5 import { loadMutedWords, contentMatchesMutedWords } from '../lib/muted-words.js' 5 6 import { createEmbeddingService } from '../services/embedding.js' ··· 269 270 } 270 271 } 271 272 273 + // Community scope: in single mode, restrict to the configured community 274 + const searchCommunityDid = 275 + env.COMMUNITY_MODE === 'single' ? getCommunityDid(env) : undefined 276 + 272 277 // Determine search mode 273 278 let searchMode: 'fulltext' | 'hybrid' = 'fulltext' 274 279 let queryEmbedding: number[] | null = null ··· 296 301 dateTo, 297 302 cursorRank, 298 303 cursorUri, 304 + communityDid: searchCommunityDid, 299 305 }, 300 306 limit + 1 301 307 ) ··· 336 342 dateTo, 337 343 cursorRank, 338 344 cursorUri, 345 + communityDid: searchCommunityDid, 339 346 }, 340 347 limit + 1 341 348 ) ··· 380 387 author, 381 388 dateFrom, 382 389 dateTo, 390 + communityDid: searchCommunityDid, 383 391 }, 384 392 limit 385 393 ) ··· 415 423 author, 416 424 dateFrom, 417 425 dateTo, 426 + communityDid: searchCommunityDid, 418 427 }, 419 428 limit 420 429 ) ··· 471 480 dateFrom, 472 481 dateTo, 473 482 searchType, 483 + communityDid: searchCommunityDid, 474 484 }) 475 485 } 476 486 ··· 507 517 dateTo?: string | undefined 508 518 cursorRank?: number | undefined 509 519 cursorUri?: string | undefined 520 + communityDid?: string | undefined 510 521 } 511 522 512 523 /** ··· 524 535 sql`is_author_deleted = false`, 525 536 ] 526 537 538 + if (filters.communityDid) { 539 + conditions.push(sql`community_did = ${filters.communityDid}`) 540 + } 527 541 if (filters.category) { 528 542 conditions.push(sql`category = ${filters.category}`) 529 543 } ··· 570 584 ): Promise<ReplySearchRow[]> { 571 585 const conditions: ReturnType<typeof sql>[] = [ 572 586 sql`r.search_vector @@ websearch_to_tsquery('english', ${query})`, 587 + sql`r.is_author_deleted = false`, 573 588 ] 574 589 590 + if (filters.communityDid) { 591 + conditions.push(sql`r.community_did = ${filters.communityDid}`) 592 + } 575 593 if (filters.author) { 576 594 conditions.push(sql`r.author_did = ${filters.author}`) 577 595 } ··· 624 642 sql`embedding <=> ${embeddingStr}::vector < 0.5`, 625 643 ] 626 644 645 + if (filters.communityDid) { 646 + conditions.push(sql`community_did = ${filters.communityDid}`) 647 + } 627 648 if (filters.category) { 628 649 conditions.push(sql`category = ${filters.category}`) 629 650 } ··· 667 688 668 689 const conditions: ReturnType<typeof sql>[] = [ 669 690 sql`r.embedding IS NOT NULL`, 691 + sql`r.is_author_deleted = false`, 670 692 sql`r.embedding <=> ${embeddingStr}::vector < 0.5`, 671 693 ] 672 694 695 + if (filters.communityDid) { 696 + conditions.push(sql`r.community_did = ${filters.communityDid}`) 697 + } 673 698 if (filters.author) { 674 699 conditions.push(sql`r.author_did = ${filters.author}`) 675 700 } ··· 710 735 dateFrom?: string | undefined 711 736 dateTo?: string | undefined 712 737 searchType: 'topics' | 'replies' | 'all' 738 + communityDid?: string | undefined 713 739 } 714 740 ): Promise<number> { 715 741 let total = 0 ··· 721 747 sql`is_author_deleted = false`, 722 748 ] 723 749 750 + if (filters.communityDid) { 751 + conditions.push(sql`community_did = ${filters.communityDid}`) 752 + } 724 753 if (filters.category) { 725 754 conditions.push(sql`category = ${filters.category}`) 726 755 } ··· 749 778 if (filters.searchType === 'replies' || filters.searchType === 'all') { 750 779 const conditions: ReturnType<typeof sql>[] = [ 751 780 sql`search_vector @@ websearch_to_tsquery('english', ${query})`, 781 + sql`is_author_deleted = false`, 752 782 ] 753 783 784 + if (filters.communityDid) { 785 + conditions.push(sql`community_did = ${filters.communityDid}`) 786 + } 754 787 if (filters.author) { 755 788 conditions.push(sql`author_did = ${filters.author}`) 756 789 }
+23
src/routes/topics.ts
··· 748 748 app.get( 749 749 '/api/topics/by-rkey/:rkey', 750 750 { 751 + preHandler: [authMiddleware.optionalAuth], 751 752 schema: { 752 753 tags: ['Topics'], 753 754 summary: 'Get a single topic by rkey (for SEO/metadata)', ··· 760 761 }, 761 762 response: { 762 763 200: topicJsonSchema, 764 + 403: errorJsonSchema, 763 765 404: errorJsonSchema, 764 766 }, 765 767 }, ··· 781 783 .from(categories) 782 784 .where(and(eq(categories.slug, row.category), eq(categories.communityDid, communityDid))) 783 785 const categoryRating = catRows[0]?.maturityRating ?? 'safe' 786 + 787 + // Maturity check: verify the topic's category is within the user's allowed level 788 + let userProfile: MaturityUser | undefined 789 + if (request.user) { 790 + const userRows = await db 791 + .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) 792 + .from(users) 793 + .where(eq(users.did, request.user.did)) 794 + userProfile = userRows[0] ?? undefined 795 + } 796 + 797 + const rkeySettingsRows = await db 798 + .select({ ageThreshold: communitySettings.ageThreshold }) 799 + .from(communitySettings) 800 + .where(eq(communitySettings.id, 'default')) 801 + const rkeyAgeThreshold = rkeySettingsRows[0]?.ageThreshold ?? 16 802 + 803 + const maxMaturity = resolveMaxMaturity(userProfile, rkeyAgeThreshold) 804 + if (!maturityAllows(maxMaturity, categoryRating)) { 805 + throw forbidden('Content restricted by maturity settings') 806 + } 784 807 785 808 return reply.status(200).send(serializeTopic(row, categoryRating)) 786 809 }
+50
tests/unit/routes/search.test.ts
··· 85 85 app.decorate('env', { 86 86 EMBEDDING_URL: undefined, 87 87 AI_EMBEDDING_DIMENSIONS: 768, 88 + COMMUNITY_MODE: 'single', 89 + COMMUNITY_DID: TEST_COMMUNITY_DID, 88 90 } as never) 89 91 app.decorate('authMiddleware', { 90 92 requireAuth: vi.fn((_req: unknown, _reply: unknown) => Promise.resolve()), ··· 524 526 results: Array<{ createdAt: string }> 525 527 }>() 526 528 expect(body.results[0]?.createdAt).toBe('2026-02-13T12:00:00.000Z') 529 + }) 530 + 531 + // ========================================================================= 532 + // Community filtering (Issue: search must include community_did) 533 + // ========================================================================= 534 + 535 + it('includes community_did in SQL queries (single mode)', async () => { 536 + // The app is configured in single mode with TEST_COMMUNITY_DID. 537 + // The search functions should include community_did in their WHERE clauses. 538 + // We verify by checking that db.execute is called (the SQL is parameterized 539 + // and includes the community_did filter internally). 540 + mockDb.execute.mockResolvedValueOnce([sampleTopicRow()]) 541 + mockDb.execute.mockResolvedValueOnce([]) 542 + 543 + const response = await app.inject({ 544 + method: 'GET', 545 + url: '/api/search?q=test&type=all', 546 + }) 547 + 548 + expect(response.statusCode).toBe(200) 549 + // Both topic and reply search should have been called 550 + expect(mockDb.execute).toHaveBeenCalledTimes(2) 551 + }) 552 + 553 + // ========================================================================= 554 + // Deletion filtering (Issue: deleted replies must not appear in search) 555 + // ========================================================================= 556 + 557 + it('excludes deleted replies from search results (deletion filter applied)', async () => { 558 + // The reply search query now includes is_author_deleted = false in the WHERE clause. 559 + // Since we mock the DB to return only non-deleted rows, the key verification is 560 + // that the route processes normally. The actual SQL filtering happens at the DB level. 561 + const nonDeletedReply = sampleReplyRow() 562 + // Reply search only (type=replies skips topic search): returns non-deleted replies 563 + mockDb.execute.mockResolvedValueOnce([nonDeletedReply]) 564 + 565 + const response = await app.inject({ 566 + method: 'GET', 567 + url: '/api/search?q=test&type=replies', 568 + }) 569 + 570 + expect(response.statusCode).toBe(200) 571 + const body = response.json<{ 572 + results: Array<{ type: string; uri: string }> 573 + }>() 574 + expect(body.results).toHaveLength(1) 575 + expect(body.results[0]?.type).toBe('reply') 576 + expect(body.results[0]?.uri).toBe(nonDeletedReply.uri) 527 577 }) 528 578 })
+117
tests/unit/routes/topics.test.ts
··· 971 971 }) 972 972 973 973 // ========================================================================= 974 + // GET /api/topics/by-rkey/:rkey 975 + // ========================================================================= 976 + 977 + describe('GET /api/topics/by-rkey/:rkey', () => { 978 + let app: FastifyInstance 979 + 980 + beforeAll(async () => { 981 + app = await buildTestApp(testUser()) 982 + }) 983 + 984 + afterAll(async () => { 985 + await app.close() 986 + }) 987 + 988 + beforeEach(() => { 989 + vi.clearAllMocks() 990 + resetAllDbMocks() 991 + }) 992 + 993 + it('returns a single topic by rkey', async () => { 994 + const row = sampleTopicRow() 995 + // 1. select().from(topics).where(rkey) -> find topic 996 + selectChain.where.mockResolvedValueOnce([row]) 997 + // 2. select(maturityRating).from(categories).where() -> category lookup 998 + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'safe' }]) 999 + // 3. select(declaredAge, maturityPref).from(users).where() -> user profile 1000 + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) 1001 + // 4. select(ageThreshold).from(communitySettings).where() -> age threshold 1002 + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) 1003 + 1004 + const response = await app.inject({ 1005 + method: 'GET', 1006 + url: '/api/topics/by-rkey/abc123', 1007 + }) 1008 + 1009 + expect(response.statusCode).toBe(200) 1010 + const body = response.json<{ uri: string; title: string; rkey: string }>() 1011 + expect(body.uri).toBe(TEST_URI) 1012 + expect(body.title).toBe('Test Topic Title') 1013 + }) 1014 + 1015 + it('returns 404 for non-existent rkey', async () => { 1016 + selectChain.where.mockResolvedValueOnce([]) 1017 + 1018 + const response = await app.inject({ 1019 + method: 'GET', 1020 + url: '/api/topics/by-rkey/nonexistent', 1021 + }) 1022 + 1023 + expect(response.statusCode).toBe(404) 1024 + }) 1025 + 1026 + it('returns 403 when maturity blocks access for unauthenticated user', async () => { 1027 + const noAuthApp = await buildTestApp(undefined) 1028 + 1029 + const row = sampleTopicRow({ category: 'mature-cat' }) 1030 + // 1. find topic 1031 + selectChain.where.mockResolvedValueOnce([row]) 1032 + // 2. category maturity rating: mature 1033 + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'mature' }]) 1034 + // 3. no user profile (unauthenticated) 1035 + // 4. age threshold 1036 + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) 1037 + 1038 + const response = await noAuthApp.inject({ 1039 + method: 'GET', 1040 + url: '/api/topics/by-rkey/abc123', 1041 + }) 1042 + 1043 + expect(response.statusCode).toBe(403) 1044 + 1045 + await noAuthApp.close() 1046 + }) 1047 + 1048 + it('allows access when user maturity level is sufficient', async () => { 1049 + const row = sampleTopicRow({ category: 'mature-cat' }) 1050 + // 1. find topic 1051 + selectChain.where.mockResolvedValueOnce([row]) 1052 + // 2. category maturity rating: mature 1053 + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'mature' }]) 1054 + // 3. user profile: adult with mature pref 1055 + selectChain.where.mockResolvedValueOnce([{ declaredAge: 18, maturityPref: 'mature' }]) 1056 + // 4. age threshold 1057 + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) 1058 + 1059 + const response = await app.inject({ 1060 + method: 'GET', 1061 + url: '/api/topics/by-rkey/abc123', 1062 + }) 1063 + 1064 + expect(response.statusCode).toBe(200) 1065 + }) 1066 + 1067 + it('works without authentication for safe content', async () => { 1068 + const noAuthApp = await buildTestApp(undefined) 1069 + 1070 + const row = sampleTopicRow() 1071 + // 1. find topic 1072 + selectChain.where.mockResolvedValueOnce([row]) 1073 + // 2. category maturity rating: safe 1074 + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'safe' }]) 1075 + // 3. no user profile (unauthenticated) 1076 + // 4. age threshold 1077 + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) 1078 + 1079 + const response = await noAuthApp.inject({ 1080 + method: 'GET', 1081 + url: '/api/topics/by-rkey/abc123', 1082 + }) 1083 + 1084 + expect(response.statusCode).toBe(200) 1085 + 1086 + await noAuthApp.close() 1087 + }) 1088 + }) 1089 + 1090 + // ========================================================================= 974 1091 // PUT /api/topics/:uri 975 1092 // ========================================================================= 976 1093