feat: Security
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
expect(JSON.stringify(tree)).toContain('data:image/webp;base64,abc');
|
||||
});
|
||||
|
||||
it('clears dangerous non-image data urls', async () => {
|
||||
const tree = await parseMarkdown('</script>)');
|
||||
|
||||
expect(JSON.stringify(tree)).not.toContain('data:text/html');
|
||||
});
|
||||
});
|
||||
@@ -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 = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user