diff --git a/tools/v1/team/shared-team-inbox/PERFORMANCE.md b/tools/v1/team/shared-team-inbox/PERFORMANCE.md new file mode 100644 index 000000000..9e94024b4 --- /dev/null +++ b/tools/v1/team/shared-team-inbox/PERFORMANCE.md @@ -0,0 +1,14 @@ +# Performance Constraints & Guidelines: Shared Team Inbox (V1) + +## 1. Resource Limits & Hard Bounds + +- **Maximum Page Size**: 50 items per virtualized or paginated fetch. +- **Maximum Preview Body Length**: 100,000 characters (~100KB text). Text exceeding this limit is truncated with a `[Truncated for performance]` placeholder. +- **Attachment List Limit**: Maximum 20 attachments displayed in primary UI preview. +- **Team Size Scale**: Local cache structures memoized per `teamId`. + +## 2. Optimization Strategies + +1. **Virtual Windowing**: Avoid rendering hidden DOM nodes for deep message threads or large team inbox lists. +2. **Lazy Evaluation**: Body sanitization and heavy HTML parsing occur on-demand when a thread is expanded, not during initial list loading. +3. **Memoization**: Filtered and sanitized inbox feeds must be wrapped in `useMemo` with strict identity tracking (`message.id` + `message.updatedAt`). diff --git a/tools/v1/team/shared-team-inbox/SECURITY.md b/tools/v1/team/shared-team-inbox/SECURITY.md new file mode 100644 index 000000000..ee18f5eba --- /dev/null +++ b/tools/v1/team/shared-team-inbox/SECURITY.md @@ -0,0 +1,16 @@ +# Threat Model & Safety Assumptions: Shared Team Inbox (V1) + +## 1. Threat Assumptions & Vectors + +| Threat Vector | Source | Impact | Mitigation Strategy | +| :--- | :--- | :--- | :--- | +| **XSS / HTML Injection** | Malicious email body / headers | Execution of arbitrary JavaScript in team context | Strict HTML sanitization; stripping dangerous tags (` World'; + expect(sanitizeMessageBody(hostile)).toBe('Hello [REDACTED SCRIPT] World'); + }); + + it('strips event handlers and javascript URIs', () => { + const hostile = 'Click me'; + const sanitized = sanitizeMessageBody(hostile); + expect(sanitized).not.toContain('onclick'); + expect(sanitized).not.toContain('javascript:'); + }); + + it('sanitizes malicious filenames', () => { + expect(sanitizeFilename('../../../etc/passwd')).toBe('.._.._.._etc_passwd'); + }); + }); + + describe('Performance Safeguards', () => { + it('enforces maximum pagination bounds of 50 items', () => { + const dummyList = Array.from({ length: 120 }, (_, i) => ({ + id: `msg-${i}`, + teamId: 'team-1', + sender: 'test@example.com', + subject: `Subj ${i}`, + body: 'Body', + timestamp: Date.now(), + attachments: [], + })); + + const page1 = paginateInboxMessages(dummyList, 1, 100); // requested 100 + expect(page1.items.length).toBe(50); // bounded to max 50 + expect(page1.hasMore).toBe(true); + }); + + it('truncates bodies exceeding character limits', () => { + const hugeBody = 'A'.repeat(150000); + const { text, isTruncated } = truncateLargeBody(hugeBody, 100000); + expect(isTruncated).toBe(true); + expect(text).toContain('[Content truncated for performance size limit]'); + }); + }); +}); diff --git a/tools/v1/team/shared-team-inbox/components/SharedInboxGuard.tsx b/tools/v1/team/shared-team-inbox/components/SharedInboxGuard.tsx new file mode 100644 index 000000000..54a0e2f3b --- /dev/null +++ b/tools/v1/team/shared-team-inbox/components/SharedInboxGuard.tsx @@ -0,0 +1,32 @@ +import React, { ReactNode } from 'react'; +import { validateSharedMessage } from '../utils/validation'; +import { sanitizeMessageBody } from '../utils/sanitization'; + +interface SharedInboxGuardProps { + rawMessage: unknown; + children: (safeProps: { sender: string; subject: string; body: string; timestamp: number }) => ReactNode; + fallback?: ReactNode; +} + +export const SharedInboxGuard: React.FC = ({ rawMessage, children, fallback }) => { + const validation = validateSharedMessage(rawMessage); + + if (!validation.isValid || !validation.data) { + return ( + <>{fallback ||
[Unsafe or Malformed Message Ignored]
} + ); + } + + const safeBody = sanitizeMessageBody(validation.data.body); + + return ( + <> + {children({ + sender: validation.data.sender, + subject: validation.data.subject, + body: safeBody, + timestamp: validation.data.timestamp, + })} + + ); +}; diff --git a/tools/v1/team/shared-team-inbox/hooks/useSharedInboxSafety.ts b/tools/v1/team/shared-team-inbox/hooks/useSharedInboxSafety.ts new file mode 100644 index 000000000..48608595a --- /dev/null +++ b/tools/v1/team/shared-team-inbox/hooks/useSharedInboxSafety.ts @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { validateSharedMessage, ValidatedSharedMessage } from '../utils/validation'; +import { sanitizeMessageBody } from '../utils/sanitization'; +import { paginateInboxMessages, truncateLargeBody, PaginatedResult } from '../utils/performance'; + +export interface UseSharedInboxSafetyOptions { + rawMessages: unknown[]; + page?: number; + pageSize?: number; +} + +export function useSharedInboxSafety({ rawMessages, page = 1, pageSize = 50 }: UseSharedInboxSafetyOptions) { + return useMemo(() => { + const validMessages: ValidatedSharedMessage[] = []; + const rejectedCount = { value: 0 }; + + if (Array.isArray(rawMessages)) { + for (const raw of rawMessages) { + const validation = validateSharedMessage(raw); + if (validation.isValid && validation.data) { + const sanitizedBody = sanitizeMessageBody(validation.data.body); + const { text: truncatedBody } = truncateLargeBody(sanitizedBody); + + validMessages.push({ + ...validation.data, + body: truncatedBody, + }); + } else { + rejectedCount.value += 1; + } + } + } + + const paginated: PaginatedResult = paginateInboxMessages(validMessages, page, pageSize); + + return { + messages: paginated.items, + hasMore: paginated.hasMore, + totalCount: paginated.totalCount, + rejectedCount: rejectedCount.value, + page: paginated.page, + }; + }, [rawMessages, page, pageSize]); +} diff --git a/tools/v1/team/shared-team-inbox/utils/performance.ts b/tools/v1/team/shared-team-inbox/utils/performance.ts new file mode 100644 index 000000000..09a43c9eb --- /dev/null +++ b/tools/v1/team/shared-team-inbox/utils/performance.ts @@ -0,0 +1,49 @@ +import { ValidatedSharedMessage } from './validation'; + +const DEFAULT_PAGE_SIZE = 50; +const PREVIEW_CHAR_LIMIT = 100000; // 100KB character threshold + +export interface PaginatedResult { + items: T[]; + hasMore: boolean; + totalCount: number; + page: number; +} + +/** + * Enforces strict pagination boundaries on large inbox datasets. + */ +export function paginateInboxMessages( + messages: ValidatedSharedMessage[], + page: number = 1, + pageSize: number = DEFAULT_PAGE_SIZE +): PaginatedResult { + const safePage = Math.max(1, page); + const safePageSize = Math.min(Math.max(1, pageSize), DEFAULT_PAGE_SIZE); + + const startIndex = (safePage - 1) * safePageSize; + const endIndex = startIndex + safePageSize; + + const slicedItems = messages.slice(startIndex, endIndex); + + return { + items: slicedItems, + hasMore: endIndex < messages.length, + totalCount: messages.length, + page: safePage, + }; +} + +/** + * Truncates oversized message body texts to prevent main thread rendering lockups. + */ +export function truncateLargeBody(body: string, limit: number = PREVIEW_CHAR_LIMIT): { text: string; isTruncated: boolean } { + if (body.length <= limit) { + return { text: body, isTruncated: false }; + } + + return { + text: body.slice(0, limit) + '\n\n[Content truncated for performance size limit]', + isTruncated: true, + }; +} diff --git a/tools/v1/team/shared-team-inbox/utils/sanitization.ts b/tools/v1/team/shared-team-inbox/utils/sanitization.ts new file mode 100644 index 000000000..ae8942951 --- /dev/null +++ b/tools/v1/team/shared-team-inbox/utils/sanitization.ts @@ -0,0 +1,22 @@ +const DANGEROUS_TAGS_REGEX = /)<[^<]*)*<\/script>/gi; +const EVENT_HANDLER_REGEX = /\s*on\w+\s*=\s*["'][^"']*["']/gi; +const JAVASCRIPT_URI_REGEX = /href\s*=\s*["']\s*javascript:[^"']*["']/gi; + +/** + * Sanitizes raw HTML or text content to eliminate common XSS vectors. + */ +export function sanitizeMessageBody(rawBody: string): string { + if (!rawBody) return ''; + + return rawBody + .replace(DANGEROUS_TAGS_REGEX, '[REDACTED SCRIPT]') + .replace(EVENT_HANDLER_REGEX, '') + .replace(JAVASCRIPT_URI_REGEX, 'href="#"'); +} + +/** + * Sanitizes headers/filenames to prevent header injection or directory traversal. + */ +export function sanitizeFilename(filename: string): string { + return filename.replace(/[/\\?%*:|"<>]/g, '_').slice(0, 255); +} diff --git a/tools/v1/team/shared-team-inbox/utils/validation.ts b/tools/v1/team/shared-team-inbox/utils/validation.ts new file mode 100644 index 000000000..74d63c414 --- /dev/null +++ b/tools/v1/team/shared-team-inbox/utils/validation.ts @@ -0,0 +1,93 @@ +export interface RawSharedMessage { + id: unknown; + teamId: unknown; + sender: unknown; + subject: unknown; + body: unknown; + timestamp: unknown; + attachments?: unknown; +} + +export interface ValidatedSharedMessage { + id: string; + teamId: string; + sender: string; + subject: string; + body: string; + timestamp: number; + attachments: Array<{ + id: string; + filename: string; + sizeBytes: number; + mimeType: string; + }>; +} + +const MAX_BODY_BYTES = 1024 * 1024; // 1 MB +const MAX_SUBJECT_LENGTH = 500; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Asserts structural and boundary safety for incoming shared team inbox messages. + */ +export function validateSharedMessage(input: unknown): { isValid: boolean; data?: ValidatedSharedMessage; error?: string } { + if (!input || typeof input !== 'object') { + return { isValid: false, error: 'Input must be a non-null object' }; + } + + const raw = input as RawSharedMessage; + + if (typeof raw.id !== 'string' || raw.id.trim() === '') { + return { isValid: false, error: 'Invalid or missing message ID' }; + } + + if (typeof raw.teamId !== 'string' || raw.teamId.trim() === '') { + return { isValid: false, error: 'Invalid or missing team ID' }; + } + + if (typeof raw.sender !== 'string' || (!EMAIL_REGEX.test(raw.sender) && !raw.sender.startsWith('G'))) { + return { isValid: false, error: 'Invalid sender format (must be valid email or Stellar public key)' }; + } + + const subject = typeof raw.subject === 'string' ? raw.subject.slice(0, MAX_SUBJECT_LENGTH) : '(No Subject)'; + + if (typeof raw.body !== 'string') { + return { isValid: false, error: 'Message body must be a string' }; + } + + if (Buffer.byteLength(raw.body, 'utf8') > MAX_BODY_BYTES) { + return { isValid: false, error: `Body size exceeds maximum threshold of ${MAX_BODY_BYTES} bytes` }; + } + + const timestamp = typeof raw.timestamp === 'number' && !isNaN(raw.timestamp) ? raw.timestamp : Date.now(); + + const attachments: ValidatedSharedMessage['attachments'] = []; + if (Array.isArray(raw.attachments)) { + for (const att of raw.attachments) { + if (att && typeof att === 'object') { + const a = att as Record; + if (typeof a.id === 'string' && typeof a.filename === 'string' && typeof a.sizeBytes === 'number') { + attachments.push({ + id: a.id, + filename: String(a.filename).slice(0, 255), + sizeBytes: Math.max(0, a.sizeBytes), + mimeType: typeof a.mimeType === 'string' ? a.mimeType : 'application/octet-stream', + }); + } + } + } + } + + return { + isValid: true, + data: { + id: raw.id, + teamId: raw.teamId, + sender: raw.sender, + subject, + body: raw.body, + timestamp, + attachments, + }, + }; +}