feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -0,0 +1,94 @@
import {
describe,
it,
expect
} from 'vitest';
import type { Message } from '../../../../shared-kernel';
import {
buildMessageHeadState,
computeMessageHeadHash,
getMessageRevision,
inventoryNeedsRefresh,
resolveMessageRevision
} from './message-integrity.rules';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'message-1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'User 1',
content: 'hello',
timestamp: 1_000,
reactions: [],
isDeleted: false,
...overrides
};
}
describe('message-integrity.rules', () => {
it('defaults missing revision to zero', () => {
expect(getMessageRevision(createMessage())).toBe(0);
expect(getMessageRevision(createMessage({ revision: 3 }))).toBe(3);
});
it('produces stable head hashes for the same canonical state', async () => {
const message = createMessage({ revision: 0 });
const state = buildMessageHeadState(message, 0);
const first = await computeMessageHeadHash(state);
const second = await computeMessageHeadHash(state);
expect(first).toBe(second);
expect(first).toMatch(/^[a-f0-9]{64}$/);
});
it('changes head hash when content or revision changes', async () => {
const original = await computeMessageHeadHash(buildMessageHeadState(createMessage({ revision: 0 }), 0));
const edited = await computeMessageHeadHash(buildMessageHeadState(createMessage({
content: 'edited',
editedAt: 2_000,
revision: 1
}), 1));
expect(edited).not.toBe(original);
});
it('requests refresh when revision is newer', () => {
expect(inventoryNeedsRefresh(
{ ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' },
{ ts: 10, rc: 0, ac: 0, revision: 0, headHash: 'aaa' }
)).toBe(true);
});
it('requests refresh when revision matches but head hash differs', () => {
expect(inventoryNeedsRefresh(
{ ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'tampered' },
{ ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'canonical' }
)).toBe(true);
});
it('does not request refresh when revision and head hash match', () => {
expect(inventoryNeedsRefresh(
{ ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'same' },
{ ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'same' }
)).toBe(false);
});
it('falls back to legacy timestamp comparison when remote inventory lacks integrity fields', () => {
expect(inventoryNeedsRefresh(
{ ts: 20, rc: 0, ac: 0 },
{ ts: 10, rc: 0, ac: 0, revision: 0, headHash: 'hash' }
)).toBe(true);
expect(inventoryNeedsRefresh(
{ ts: 10, rc: 1, ac: 0 },
{ ts: 10, rc: 0, ac: 0, revision: 0, headHash: 'hash' }
)).toBe(true);
});
it('resolves next revision for create, edit, and delete flows', () => {
expect(resolveMessageRevision(undefined, 'create')).toBe(0);
expect(resolveMessageRevision(createMessage({ revision: 0 }), 'author-edit')).toBe(1);
expect(resolveMessageRevision(createMessage({ revision: 4 }), 'moderate-delete')).toBe(5);
});
});

View File

@@ -0,0 +1,136 @@
import type { Message } from '../../../../shared-kernel';
import type { MessageRevisionType } from '../../../../shared-kernel/message-revision.models';
import { getMessageTimestamp } from './message.rules';
export interface MessageHeadState {
messageId: string;
revision: number;
senderId: string;
content: string;
isDeleted: boolean;
editedAt: number;
channelId: string;
replyToId: string;
}
export interface InventoryIntegritySnapshot {
ts: number;
rc: number;
ac: number;
revision: number;
headHash: string;
}
export type RemoteInventoryItem = {
id: string;
ts: number;
rc?: number;
ac?: number;
revision?: number;
headHash?: string;
};
export type MessageRevisionAction = MessageRevisionType;
export function getMessageRevision(message: Pick<Message, 'revision'> | null | undefined): number {
return typeof message?.revision === 'number' && message.revision >= 0
? message.revision
: 0;
}
export function resolveMessageRevision(
existing: Pick<Message, 'revision'> | null | undefined,
_action: MessageRevisionAction
): number {
return getMessageRevision(existing ?? undefined) + (existing ? 1 : 0);
}
export function buildMessageHeadState(message: Message, revision = getMessageRevision(message)): MessageHeadState {
return {
messageId: message.id,
revision,
senderId: message.senderId,
content: message.isDeleted ? '' : message.content,
isDeleted: message.isDeleted,
editedAt: getMessageTimestamp(message),
channelId: message.channelId ?? 'general',
replyToId: message.replyToId ?? ''
};
}
export async function computeMessageHeadHash(state: MessageHeadState): Promise<string> {
const canonical = JSON.stringify(state, Object.keys(state).sort());
if (typeof globalThis.crypto?.subtle?.digest !== 'function') {
throw new Error('Web Crypto digest is unavailable for message integrity hashing');
}
const digest = await globalThis.crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(canonical)
);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
export async function computeMessageHeadHashFromMessage(
message: Message,
revision = getMessageRevision(message)
): Promise<string> {
return await computeMessageHeadHash(buildMessageHeadState(message, revision));
}
function hasIntegrityFields(item: RemoteInventoryItem): item is RemoteInventoryItem & {
revision: number;
headHash: string;
} {
return typeof item.revision === 'number'
&& typeof item.headHash === 'string'
&& item.headHash.length > 0;
}
export function inventoryNeedsRefresh(
remote: RemoteInventoryItem,
local: InventoryIntegritySnapshot
): boolean {
if (!hasIntegrityFields(remote)) {
return remote.ts > local.ts
|| (remote.rc !== undefined && remote.rc !== local.rc)
|| (remote.ac !== undefined && remote.ac !== local.ac);
}
if (remote.revision > local.revision) {
return true;
}
if (remote.revision < local.revision) {
return false;
}
if (remote.headHash !== local.headHash) {
return true;
}
return remote.ts > local.ts
|| (remote.rc !== undefined && remote.rc !== local.rc)
|| (remote.ac !== undefined && remote.ac !== local.ac);
}
export function shouldApplyIncomingRevision(
incomingRevision: number,
existingRevision: number,
incomingHeadHash: string,
existingHeadHash: string
): boolean {
if (incomingRevision > existingRevision) {
return true;
}
if (incomingRevision < existingRevision) {
return false;
}
return incomingHeadHash !== existingHeadHash;
}

View File

@@ -0,0 +1,48 @@
import {
describe,
it,
expect,
vi
} from 'vitest';
import type { MessageRevision } from '../../../../shared-kernel';
import {
attachRevisionSignatureIfPossible,
shouldAcceptRevisionWithoutRegisteredKey
} from './message-revision-signing.rules';
describe('message-revision-signing.rules', () => {
const revision: MessageRevision = {
messageId: 'msg-1',
revision: 0,
prevRevisionHash: '',
headHash: 'hash',
type: 'create',
actorId: 'user-1',
senderId: 'user-1',
roomId: 'room-1',
channelId: 'general',
senderName: 'User',
content: 'hello',
editedAt: 1,
isDeleted: false
};
it('keeps revisions unsigned when signing fails', async () => {
const signed = await attachRevisionSignatureIfPossible(
revision,
vi.fn(async () => {
throw new Error('Ed25519 unavailable');
})
);
expect(signed.signature).toBeUndefined();
});
it('accepts signed revisions while the sender key is still registering', () => {
expect(shouldAcceptRevisionWithoutRegisteredKey({
...revision,
signature: 'signature'
}, null)).toBe(true);
expect(shouldAcceptRevisionWithoutRegisteredKey(revision, null)).toBe(false);
});
});

View File

@@ -0,0 +1,24 @@
import type { MessageRevision } from '../../../../shared-kernel';
export async function attachRevisionSignatureIfPossible(
revision: MessageRevision,
signRevision: (value: MessageRevision) => Promise<string>
): Promise<MessageRevision> {
try {
const signature = await signRevision(revision);
return {
...revision,
signature
};
} catch {
return revision;
}
}
export function shouldAcceptRevisionWithoutRegisteredKey(
revision: MessageRevision,
publicKeyJwk: JsonWebKey | null
): boolean {
return !!revision.signature && !publicKeyJwk;
}

View File

@@ -0,0 +1,94 @@
import {
describe,
it,
expect
} from 'vitest';
import type { Message } from '../../../../shared-kernel';
import {
buildMessageRevision,
materializeMessageFromRevision,
revisionBeatsMessage
} from './message-revision.builder.rules';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'message-1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'User 1',
content: 'hello',
timestamp: 1_000,
reactions: [],
isDeleted: false,
...overrides
};
}
describe('message-revision.builder.rules', () => {
it('builds create revisions at revision zero', async () => {
const message = createMessage();
const revision = await buildMessageRevision({
message,
type: 'create',
actorId: 'user-1',
editedAt: 1_000
});
expect(revision.revision).toBe(0);
expect(revision.headHash).toMatch(/^[a-f0-9]{64}$/);
expect(revision.type).toBe('create');
});
it('increments revision for author edits', async () => {
const message = createMessage({ revision: 0, headHash: 'abc' });
const revision = await buildMessageRevision({
message,
type: 'author-edit',
actorId: 'user-1',
content: 'edited',
editedAt: 2_000,
prevRevisionHash: 'abc'
});
expect(revision.revision).toBe(1);
expect(revision.prevRevisionHash).toBe('abc');
expect(revision.content).toBe('edited');
});
it('materializes message state from a revision', async () => {
const revision = await buildMessageRevision({
message: createMessage(),
type: 'author-edit',
actorId: 'user-1',
content: 'edited',
editedAt: 2_000
});
const materialized = materializeMessageFromRevision(createMessage(), revision);
expect(materialized.revision).toBe(1);
expect(materialized.content).toBe('edited');
expect(materialized.headHash).toBe(revision.headHash);
});
it('detects when a revision should replace local state', async () => {
const local = materializeMessageFromRevision(
createMessage({ revision: 0, headHash: 'old' }),
await buildMessageRevision({
message: createMessage(),
type: 'create',
actorId: 'user-1',
editedAt: 1_000
})
);
const incoming = await buildMessageRevision({
message: createMessage({ revision: 0, headHash: local.headHash }),
type: 'author-edit',
actorId: 'user-1',
content: 'edited',
editedAt: 2_000,
prevRevisionHash: local.headHash ?? ''
});
expect(revisionBeatsMessage(incoming, local)).toBe(true);
});
});

View File

@@ -0,0 +1,108 @@
import {
DELETED_MESSAGE_CONTENT,
type Message,
type MessageRevision,
type MessageRevisionType
} from '../../../../shared-kernel';
import {
buildMessageHeadState,
computeMessageHeadHash,
getMessageRevision,
resolveMessageRevision
} from './message-integrity.rules';
import { getMessageTimestamp } from './message.rules';
export interface BuildMessageRevisionInput {
message: Message;
type: MessageRevisionType;
actorId: string;
content?: string;
editedAt: number;
isDeleted?: boolean;
pluginId?: string;
prevRevisionHash?: string;
signature?: string;
}
export async function buildMessageRevision(input: BuildMessageRevisionInput): Promise<MessageRevision> {
const revision = input.type === 'create'
? 0
: resolveMessageRevision(input.message, input.type);
const isDeleted = input.isDeleted ?? input.type.includes('delete');
const content = isDeleted
? DELETED_MESSAGE_CONTENT
: (input.content ?? input.message.content);
const materializedMessage: Message = {
...input.message,
content,
editedAt: input.editedAt,
isDeleted,
revision
};
const headHash = await computeMessageHeadHash(buildMessageHeadState(materializedMessage, revision));
return {
messageId: input.message.id,
revision,
prevRevisionHash: input.prevRevisionHash ?? '',
headHash,
type: input.type,
actorId: input.actorId,
senderId: input.message.senderId,
roomId: input.message.roomId,
channelId: input.message.channelId,
senderName: input.message.senderName,
content,
editedAt: input.editedAt,
isDeleted,
replyToId: input.message.replyToId,
pluginId: input.pluginId,
signature: input.signature
};
}
export function materializeMessageFromRevision(
existing: Message | null,
revision: MessageRevision
): Message {
const base = existing ?? {
id: revision.messageId,
roomId: revision.roomId,
channelId: revision.channelId,
senderId: revision.senderId,
senderName: revision.senderName ?? 'Unknown',
content: revision.content ?? '',
timestamp: revision.editedAt,
reactions: [],
isDeleted: revision.isDeleted,
replyToId: revision.replyToId
};
return {
...base,
roomId: revision.roomId,
channelId: revision.channelId ?? base.channelId,
senderId: revision.senderId,
senderName: revision.senderName ?? base.senderName,
content: revision.isDeleted ? DELETED_MESSAGE_CONTENT : (revision.content ?? base.content),
editedAt: revision.editedAt,
revision: revision.revision,
headHash: revision.headHash,
isDeleted: revision.isDeleted,
replyToId: revision.replyToId ?? base.replyToId
};
}
export function revisionTimestamp(revision: MessageRevision): number {
return revision.editedAt;
}
export function revisionBeatsMessage(revision: MessageRevision, message: Message | null): boolean {
const existingRevision = getMessageRevision(message ?? undefined);
if (revision.revision !== existingRevision) {
return revision.revision > existingRevision;
}
return revision.headHash !== message?.headHash;
}

View File

@@ -0,0 +1,23 @@
import {
describe,
it,
expect
} from 'vitest';
import { findMissingIds } from './message-sync.rules';
describe('message-sync.rules', () => {
it('requests ids with newer revision or mismatched head hash', () => {
const localMap = new Map([
['m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }],
['m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }]
]);
const missing = findMissingIds([
{ id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' },
{ id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' },
{ id: 'm3', ts: 1, rc: 0, ac: 0, revision: 0, headHash: 'ddd' }
], localMap);
expect(missing).toEqual(['m1', 'm3']);
});
});

View File

@@ -1,3 +1,9 @@
import {
inventoryNeedsRefresh,
type InventoryIntegritySnapshot,
type RemoteInventoryItem
} from './message-integrity.rules';
/** Maximum number of messages to include in sync inventories.
*
* The inventory protocol now ships every message in the room (id, ts, rc, ac)
@@ -27,7 +33,9 @@ export interface InventoryItem {
id: string;
ts: number;
rc: number;
ac?: number;
ac: number;
revision: number;
headHash: string;
}
/** Splits an array into chunks of the given size. */
@@ -43,15 +51,15 @@ export function chunkArray<T>(items: T[], size: number): T[][] {
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
export function findMissingIds(
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
remoteItems: readonly RemoteInventoryItem[],
localMap: ReadonlyMap<string, InventoryIntegritySnapshot | InventoryItem>
): string[] {
const missing: string[] = [];
for (const item of remoteItems) {
const local = localMap.get(item.id);
if (!local || item.ts > local.ts || (item.rc !== undefined && item.rc !== local.rc) || (item.ac !== undefined && item.ac !== local.ac)) {
if (!local || inventoryNeedsRefresh(item, local)) {
missing.push(item.id);
}
}

View File

@@ -0,0 +1,42 @@
import {
describe,
it,
expect
} from 'vitest';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { remarkStripDangerousContent } from './remark-sanitize.rules';
async function parseMarkdown(content: string) {
const processor = unified()
.use(remarkParse)
.use(remarkStripDangerousContent());
return processor.run(processor.parse(content));
}
describe('remarkStripDangerousContent', () => {
it('removes raw html nodes from markdown', async () => {
const tree = await parseMarkdown('Hello <script>alert(1)</script> world');
expect(JSON.stringify(tree)).not.toContain('script');
});
it('clears dangerous link protocols', async () => {
const tree = await parseMarkdown('[click](javascript:alert(1))');
expect(JSON.stringify(tree)).not.toContain('javascript:');
});
it('preserves safe custom emoji image data urls', async () => {
const tree = await parseMarkdown('![custom-emoji:party-id:party](data:image/webp;base64,abc)');
expect(JSON.stringify(tree)).toContain('data:image/webp;base64,abc');
});
it('clears dangerous non-image data urls', async () => {
const tree = await parseMarkdown('![x](data:text/html,<script>alert(1)</script>)');
expect(JSON.stringify(tree)).not.toContain('data:text/html');
});
});

View File

@@ -0,0 +1,46 @@
import type { Parent, Root } from 'mdast';
import { visit } from 'unist-util-visit';
const ALLOWED_DATA_IMAGE_MIME_PATTERN = /^data:image\/(?:webp|gif|jpe?g|png)(?:;|$)/i;
function isBlockedMarkdownUrl(url: string): boolean {
const trimmed = url.trim();
if (/^(?:javascript|vbscript):/i.test(trimmed)) {
return true;
}
if (/^data:/i.test(trimmed)) {
return !ALLOWED_DATA_IMAGE_MIME_PATTERN.test(trimmed);
}
return false;
}
export function remarkStripDangerousContent(): () => (tree: Root) => void {
return () => (tree: Root) => {
const htmlNodes: { parent: Parent; index: number }[] = [];
visit(tree, 'html', (_node, index, parent) => {
if (parent && typeof index === 'number') {
htmlNodes.push({ parent, index });
}
});
for (const entry of htmlNodes.sort((left, right) => right.index - left.index)) {
entry.parent.children.splice(entry.index, 1);
}
visit(tree, 'link', (node) => {
if (typeof node.url === 'string' && isBlockedMarkdownUrl(node.url)) {
node.url = '';
}
});
visit(tree, 'image', (node) => {
if (typeof node.url === 'string' && isBlockedMarkdownUrl(node.url)) {
node.url = '';
}
});
};
}