Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions tools/v1/team/shared-team-inbox/PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -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`).
16 changes: 16 additions & 0 deletions tools/v1/team/shared-team-inbox/SECURITY.md
Original file line number Diff line number Diff line change
@@ -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 (`<script>`, `<iframe>`, `object`) and attributes (`onload`, `onerror`, `javascript:` URLs). |
| **Payload DoS** | Oversized email bodies, nested MIME attachments | Browser memory exhaustion / tab freezing | Max body payload limit (1MB max, auto-truncated preview at 100KB); attachment metadata validation. |
| **Header / Metadata Spoofing** | Invalid/malformed sender headers | UI misleading / impersonation | Strict schema validation on `sender`, `recipient`, `timestamp`, and `teamId`. |
| **Resource Exhaustion** | Unbounded list sizes (thousands of emails) | DOM node bloat, rendering lag | Forced pagination limits (max 50 items/page) and thread depth truncation. |

## 2. Unsafe Inputs & Redaction Rules

- **Emails with Inline JavaScript**: All inline scripts and `data:` or `javascript:` URI schemes are stripped prior to rendering.
- **Malformed Team Context**: Missing or invalid `teamId` formats are immediately flagged and isolated by guard helpers.
- **Oversized Attachments**: Attachments exceeding 25MB are flagged as unprocessable in-browser and require stream handling.
74 changes: 74 additions & 0 deletions tools/v1/team/shared-team-inbox/__tests__/safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { validateSharedMessage } from '../utils/validation';
import { sanitizeMessageBody, sanitizeFilename } from '../utils/sanitization';
import { paginateInboxMessages, truncateLargeBody } from '../utils/performance';

describe('Shared Team Inbox Safety & Constraints (#447)', () => {
describe('Input Validation', () => {
it('rejects null or non-object inputs', () => {
expect(validateSharedMessage(null).isValid).toBe(false);
expect(validateSharedMessage('string').isValid).toBe(false);
});

it('rejects missing teamId or sender', () => {
const invalid = { id: 'msg-1', body: 'hello' };
expect(validateSharedMessage(invalid).isValid).toBe(false);
});

it('accepts valid raw message', () => {
const validRaw = {
id: 'msg-101',
teamId: 'team-alpha',
sender: 'alice@stellar.org',
subject: 'Weekly Digest',
body: 'Hello Team!',
timestamp: 1785110000,
};
const result = validateSharedMessage(validRaw);
expect(result.isValid).toBe(true);
expect(result.data?.subject).toBe('Weekly Digest');
});
});

describe('Sanitizer Utility', () => {
it('strips inline script tags', () => {
const hostile = 'Hello <script>alert("xss")</script> World';
expect(sanitizeMessageBody(hostile)).toBe('Hello [REDACTED SCRIPT] World');
});

it('strips event handlers and javascript URIs', () => {
const hostile = '<a href="javascript:alert(1)" onclick="steal()">Click me</a>';
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]');
});
});
});
32 changes: 32 additions & 0 deletions tools/v1/team/shared-team-inbox/components/SharedInboxGuard.tsx
Original file line number Diff line number Diff line change
@@ -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<SharedInboxGuardProps> = ({ rawMessage, children, fallback }) => {
const validation = validateSharedMessage(rawMessage);

if (!validation.isValid || !validation.data) {
return (
<>{fallback || <div style={{ color: 'red', padding: '8px' }}>[Unsafe or Malformed Message Ignored]</div>}</>
);
}

const safeBody = sanitizeMessageBody(validation.data.body);

return (
<>
{children({
sender: validation.data.sender,
subject: validation.data.subject,
body: safeBody,
timestamp: validation.data.timestamp,
})}
</>
);
};
44 changes: 44 additions & 0 deletions tools/v1/team/shared-team-inbox/hooks/useSharedInboxSafety.ts
Original file line number Diff line number Diff line change
@@ -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<ValidatedSharedMessage> = paginateInboxMessages(validMessages, page, pageSize);

return {
messages: paginated.items,
hasMore: paginated.hasMore,
totalCount: paginated.totalCount,
rejectedCount: rejectedCount.value,
page: paginated.page,
};
}, [rawMessages, page, pageSize]);
}
49 changes: 49 additions & 0 deletions tools/v1/team/shared-team-inbox/utils/performance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ValidatedSharedMessage } from './validation';

const DEFAULT_PAGE_SIZE = 50;
const PREVIEW_CHAR_LIMIT = 100000; // 100KB character threshold

export interface PaginatedResult<T> {
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<ValidatedSharedMessage> {
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,
};
}
22 changes: 22 additions & 0 deletions tools/v1/team/shared-team-inbox/utils/sanitization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const DANGEROUS_TAGS_REGEX = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/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);
}
93 changes: 93 additions & 0 deletions tools/v1/team/shared-team-inbox/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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,
},
};
}
Loading