@things-factory/board-ai 10.0.0-beta.65 → 10.0.0-beta.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/client/components/markdown.test.ts +78 -0
  2. package/client/components/markdown.ts +87 -4
  3. package/client/utils/board-edit-patch.ts +68 -6
  4. package/dist-client/client/components/markdown.d.ts +8 -0
  5. package/dist-client/client/components/markdown.js +83 -4
  6. package/dist-client/client/components/markdown.js.map +1 -1
  7. package/dist-client/client/components/markdown.test.js +65 -1
  8. package/dist-client/client/components/markdown.test.js.map +1 -1
  9. package/dist-client/client/utils/board-edit-patch.js +68 -4
  10. package/dist-client/client/utils/board-edit-patch.js.map +1 -1
  11. package/dist-client/server/service/agentic-loop.d.ts +87 -0
  12. package/dist-client/server/service/agentic-loop.js +274 -0
  13. package/dist-client/server/service/agentic-loop.js.map +1 -0
  14. package/dist-client/server/service/assistant.d.ts +8 -0
  15. package/dist-client/server/service/assistant.js +138 -428
  16. package/dist-client/server/service/assistant.js.map +1 -1
  17. package/dist-client/server/service/component-tree.d.ts +68 -0
  18. package/dist-client/server/service/component-tree.js +68 -0
  19. package/dist-client/server/service/component-tree.js.map +1 -0
  20. package/dist-client/server/service/styling/effect-tools.d.ts +7 -1
  21. package/dist-client/server/service/styling/effect-tools.js +10 -9
  22. package/dist-client/server/service/styling/effect-tools.js.map +1 -1
  23. package/dist-client/server/service/styling/fill-tools.d.ts +2 -2
  24. package/dist-client/server/service/styling/fill-tools.js +4 -4
  25. package/dist-client/server/service/styling/fill-tools.js.map +1 -1
  26. package/dist-client/server/service/styling/registry.d.ts +91 -0
  27. package/dist-client/server/service/styling/registry.js +345 -0
  28. package/dist-client/server/service/styling/registry.js.map +1 -0
  29. package/dist-client/server/service/styling/stroke-tools.d.ts +5 -2
  30. package/dist-client/server/service/styling/stroke-tools.js +13 -13
  31. package/dist-client/server/service/styling/stroke-tools.js.map +1 -1
  32. package/dist-client/server/service/validation/board-model-schema.d.ts +6076 -895
  33. package/dist-client/server/service/validation/board-model-schema.js +122 -4
  34. package/dist-client/server/service/validation/board-model-schema.js.map +1 -1
  35. package/dist-client/server/service/validation/tool-validation.js +43 -6
  36. package/dist-client/server/service/validation/tool-validation.js.map +1 -1
  37. package/dist-client/tsconfig.tsbuildinfo +1 -1
  38. package/dist-server/service/agentic-loop.d.ts +87 -0
  39. package/dist-server/service/agentic-loop.js +278 -0
  40. package/dist-server/service/agentic-loop.js.map +1 -0
  41. package/dist-server/service/apply-patch.js +83 -7
  42. package/dist-server/service/apply-patch.js.map +1 -1
  43. package/dist-server/service/assistant.d.ts +8 -0
  44. package/dist-server/service/assistant.js +139 -428
  45. package/dist-server/service/assistant.js.map +1 -1
  46. package/dist-server/service/component-tree.d.ts +68 -0
  47. package/dist-server/service/component-tree.js +73 -0
  48. package/dist-server/service/component-tree.js.map +1 -0
  49. package/dist-server/service/styling/effect-tools.d.ts +7 -1
  50. package/dist-server/service/styling/effect-tools.js +10 -9
  51. package/dist-server/service/styling/effect-tools.js.map +1 -1
  52. package/dist-server/service/styling/fill-tools.d.ts +2 -2
  53. package/dist-server/service/styling/fill-tools.js +4 -4
  54. package/dist-server/service/styling/fill-tools.js.map +1 -1
  55. package/dist-server/service/styling/registry.d.ts +91 -0
  56. package/dist-server/service/styling/registry.js +352 -0
  57. package/dist-server/service/styling/registry.js.map +1 -0
  58. package/dist-server/service/styling/stroke-tools.d.ts +5 -2
  59. package/dist-server/service/styling/stroke-tools.js +13 -13
  60. package/dist-server/service/styling/stroke-tools.js.map +1 -1
  61. package/dist-server/service/validation/board-model-schema.d.ts +6149 -968
  62. package/dist-server/service/validation/board-model-schema.js +124 -5
  63. package/dist-server/service/validation/board-model-schema.js.map +1 -1
  64. package/dist-server/service/validation/tool-validation.js +42 -5
  65. package/dist-server/service/validation/tool-validation.js.map +1 -1
  66. package/dist-server/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +5 -4
  68. package/server/service/agentic-loop.test.ts +705 -0
  69. package/server/service/agentic-loop.ts +428 -0
  70. package/server/service/apply-patch-drift.test.ts +516 -0
  71. package/server/service/apply-patch.ts +90 -9
  72. package/server/service/assistant-integration.test.ts +4 -4
  73. package/server/service/assistant.test.ts +14 -3
  74. package/server/service/assistant.ts +153 -444
  75. package/server/service/component-tree.test.ts +248 -0
  76. package/server/service/component-tree.ts +135 -0
  77. package/server/service/styling/clear-tools.test.ts +178 -0
  78. package/server/service/styling/effect-tools.test.ts +259 -0
  79. package/server/service/styling/effect-tools.ts +17 -10
  80. package/server/service/styling/fill-tools.test.ts +7 -7
  81. package/server/service/styling/fill-tools.ts +6 -6
  82. package/server/service/styling/material-tools.test.ts +294 -0
  83. package/server/service/styling/registry.test.ts +203 -0
  84. package/server/service/styling/registry.ts +423 -0
  85. package/server/service/styling/stroke-tools.test.ts +231 -0
  86. package/server/service/styling/stroke-tools.ts +17 -14
  87. package/server/service/styling/text-tools.test.ts +235 -0
  88. package/server/service/styling/tier1-tools.test.ts +19 -18
  89. package/server/service/validation/board-model-schema.test.ts +478 -15
  90. package/server/service/validation/board-model-schema.ts +138 -4
  91. package/server/service/validation/tool-validation.ts +46 -5
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import {
11
11
  renderMarkdown,
12
+ sanitizeHtml,
12
13
  escapeHtml,
13
14
  extractCjkStrongs,
14
15
  extractMentions,
@@ -95,6 +96,83 @@ describe('renderMarkdown — 보안', () => {
95
96
  })
96
97
  })
97
98
 
99
+ describe('sanitizeHtml — DOMPurify defense-in-depth', () => {
100
+ // Node 환경 (jest) 에선 window 부재 → fallback 동작 검증.
101
+ // 브라우저에서의 실제 정화는 통합 테스트 영역.
102
+
103
+ test('window 부재 시 입력 그대로 통과 (fallback)', () => {
104
+ // 직접 호출 — window undefined 환경에서 DOMPurify 안 도는 게 정상
105
+ const html = '<p>safe</p><script>bad</script>'
106
+ const out = sanitizeHtml(html)
107
+ // 노드 환경에서 fallback 이라 그대로 통과 — 브라우저 환경에선 script 제거됨
108
+ expect(typeof out).toBe('string')
109
+ })
110
+
111
+ test('window 있을 때 (jsdom mock) — script 차단 확인', () => {
112
+ // jsdom 은 jest 의 testEnvironment 가 jsdom 일 때만 동작 — 본 setup 은 node.
113
+ // 본 test 는 sanitizeHtml 의 *contract* 만 확인 (string in → string out).
114
+ const out = sanitizeHtml('<p>hello</p>')
115
+ expect(out).toContain('hello')
116
+ })
117
+
118
+ test('빈 문자열 안전 처리', () => {
119
+ expect(sanitizeHtml('')).toBe('')
120
+ })
121
+
122
+ test('null/undefined fallback', () => {
123
+ // 타입상 string 만 받지만 방어적으로 — 빈 문자열로 처리
124
+ expect(sanitizeHtml('' as any)).toBe('')
125
+ })
126
+ })
127
+
128
+ describe('renderMarkdown — XSS 우회 시도 차단 (defense-in-depth)', () => {
129
+ // 본 테스트들은 marked 의 1차 차단 + DOMPurify 의 2차 차단이 모두 작동하는지
130
+ // 회귀 방지. node 환경에선 sanitizeHtml 이 fallback 이지만, marked 의 1차
131
+ // renderer.html → '' 차단만으로도 대부분 케이스 차단 검증 가능.
132
+
133
+ test('이미지 onerror 핸들러 시도 — img 태그 자체에 onerror attribute 없음', () => {
134
+ const out = renderMarkdown('![alt](x" onerror="alert(1))')
135
+ // 결과 텍스트에 escape 된 형태로 onerror= 라는 글자 자체는 남을 수 있지만,
136
+ // *실행 가능한 attribute* 형태 (속성 이름으로) 는 없어야 한다.
137
+ // 정밀 검사: <img 태그 안에 onerror=" 형태가 없는지
138
+ expect(out).not.toMatch(/<img\b[^>]*\bonerror\s*=/i)
139
+ // 또한 escape 안 된 따옴표 + onerror 조합 자체도 없어야
140
+ expect(out).not.toMatch(/"\s+onerror=/i)
141
+ })
142
+
143
+ test('img src=javascript: 차단', () => {
144
+ const out = renderMarkdown('![x](javascript:alert(1))')
145
+ expect(out).not.toMatch(/src="javascript:/i)
146
+ })
147
+
148
+ test('SVG xss 패턴 차단', () => {
149
+ const out = renderMarkdown('<svg onload=alert(1)>')
150
+ expect(out).not.toContain('<svg')
151
+ expect(out).not.toMatch(/onload/i)
152
+ })
153
+
154
+ test('style="javascript:..." 차단', () => {
155
+ const out = renderMarkdown('[click](#){:style="javascript:alert(1)"}')
156
+ expect(out).not.toMatch(/style="javascript:/i)
157
+ })
158
+
159
+ test('mention span 안의 사용자 token escape', () => {
160
+ const out = renderMarkdown('#"><script>alert(1)</script>')
161
+ expect(out).not.toMatch(/<script>/i)
162
+ })
163
+
164
+ test('event handler attribute 차단 (onmouseover 등)', () => {
165
+ // raw HTML 이 marked.renderer.html 에서 빈 문자열로 → onmouseover 자체가 출력 안됨
166
+ const out = renderMarkdown('<a href="#" onmouseover="alert(1)">x</a>')
167
+ expect(out).not.toMatch(/onmouseover/i)
168
+ })
169
+
170
+ test('vbscript: 스킴 차단 (legacy IE 잔재)', () => {
171
+ const out = renderMarkdown('[x](vbscript:msgbox(1))')
172
+ expect(out).not.toMatch(/href="vbscript:/i)
173
+ })
174
+ })
175
+
98
176
  describe('renderMarkdown — CJK + 구두점 인접 (회귀 방지)', () => {
99
177
  // 이 그룹은 사용자 보고 사례. CommonMark flanking 규칙으로 인해 ** 가
100
178
  // 닫히지 않던 케이스들을 모두 회복해야 한다.
@@ -1,8 +1,18 @@
1
1
  /**
2
2
  * Board AI 채팅 메시지의 Markdown 렌더링.
3
3
  *
4
- * 보안:
5
- * - raw HTML 차단 (renderer.html → ''), 위험 link 스킴 (javascript:, data: 등) 차단.
4
+ * 보안 (defense-in-depth):
5
+ * 1. raw HTML 차단 — marked 의 renderer.html → '' block-level HTML 무력화.
6
+ * 2. 위험 link 스킴 차단 — javascript: / data: / vbscript: 등 거부.
7
+ * 3. DOMPurify 후처리 — marked 출력 전체를 sanitize. 우회되는 inline HTML
8
+ * (이미지 onerror, span style="javascript:..." 등) 까지 차단.
9
+ *
10
+ * 1·2 만으로도 표면이 좁지만, 다음의 *알려진 우회 경로* 가 존재하므로 3 이 필수:
11
+ * - marked 가 토큰화 실패한 일부 입력 (특히 코드블록 안 백틱 escape 미스) 에서
12
+ * 생 HTML 이 escape 안 된 채 흘러나갈 수 있음
13
+ * - mention span (`<span class="mention" ...>`) 이 우리 손으로 만드는 element 라
14
+ * allowlist 기반 정화 필요
15
+ * - 멀티유저 환경 (다른 사용자 메시지 echo) 시 표면 ↑
6
16
  *
7
17
  * CJK 보강:
8
18
  * - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와
@@ -13,19 +23,69 @@
13
23
  * - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은
14
24
  * escapeHtml 로 항상 escape).
15
25
  */
26
+ import DOMPurify from 'dompurify'
16
27
  import { marked } from 'marked'
17
28
 
29
+ /**
30
+ * DOMPurify 정화 정책.
31
+ *
32
+ * 우리가 markdown 출력에서 의도적으로 사용하는 element / attribute 만 명시 허용:
33
+ * - 표준 markdown elements (p, h1~6, ul/ol/li, strong/em, a, img, code, pre, blockquote, hr, br, table 계열)
34
+ * - mention span (class + data-refid + data-userid)
35
+ *
36
+ * 그 외는 모두 strip — 이벤트 핸들러 (onerror/onclick), inline style, javascript:
37
+ * URL 등 자동 차단. 우리 구조에 없는 element (script/iframe/object/embed 등) 도 모두 제거.
38
+ */
39
+ const PURIFY_CONFIG: any = {
40
+ ALLOWED_TAGS: [
41
+ 'p', 'br', 'hr',
42
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
43
+ 'strong', 'em', 'del', 's', 'u',
44
+ 'ul', 'ol', 'li',
45
+ 'a', 'img',
46
+ 'code', 'pre',
47
+ 'blockquote',
48
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
49
+ 'span'
50
+ ],
51
+ ALLOWED_ATTR: [
52
+ 'href', 'title', 'alt', 'src', 'class',
53
+ 'data-refid', 'data-userid'
54
+ ],
55
+ // javascript: / data: / vbscript: 등 위험 스킴 자동 차단
56
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
57
+ // target="_blank" 같이 우리가 명시하는 link 외엔 추가 attr 안 붙이도록
58
+ ADD_ATTR: ['target', 'rel'],
59
+ // raw HTML 입력은 marked.renderer.html → '' 로 이미 막혀있지만,
60
+ // dompurify 가 전체 input 을 한 번 더 정화 — 우회 차단
61
+ KEEP_CONTENT: true
62
+ }
63
+
18
64
  const markdownRenderer = new marked.Renderer()
19
65
  markdownRenderer.html = () => ''
66
+
67
+ const DANGEROUS_SCHEME_RE = /^(javascript|data|vbscript|file):/i
68
+
20
69
  const origLink = markdownRenderer.link.bind(markdownRenderer)
21
70
  markdownRenderer.link = (token: any) => {
22
71
  const href = typeof token?.href === 'string' ? token.href.trim() : ''
23
- if (/^(javascript|data|vbscript):/i.test(href)) {
72
+ if (DANGEROUS_SCHEME_RE.test(href)) {
24
73
  return token?.text ?? ''
25
74
  }
26
75
  return origLink(token)
27
76
  }
28
77
 
78
+ // 이미지 renderer — src scheme 검사 + alt 의 inline HTML escape
79
+ const origImage = markdownRenderer.image.bind(markdownRenderer)
80
+ markdownRenderer.image = (token: any) => {
81
+ const src = typeof token?.href === 'string' ? token.href.trim() : ''
82
+ if (DANGEROUS_SCHEME_RE.test(src)) {
83
+ // 위험 src — 이미지 자체 제거. alt 텍스트는 escape 해서 평문 노출
84
+ return escapeHtml(token?.text ?? '')
85
+ }
86
+ return origImage(token)
87
+ }
88
+
29
89
  marked.setOptions({
30
90
  gfm: true,
31
91
  breaks: true,
@@ -418,8 +478,31 @@ export function renderMarkdown(text: string): string {
418
478
  return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>')
419
479
  }
420
480
  // 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.
421
- return restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps)
481
+ const restored = restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps)
482
+ // 4) DOMPurify 후처리 — defense-in-depth. marked / 우리 mention span 둘 다 한 번 더 정화.
483
+ return sanitizeHtml(restored)
422
484
  } catch {
423
485
  return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>')
424
486
  }
425
487
  }
488
+
489
+ /**
490
+ * DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.
491
+ *
492
+ * 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.
493
+ * Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback
494
+ * (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).
495
+ */
496
+ export function sanitizeHtml(html: string): string {
497
+ try {
498
+ if (typeof window === 'undefined' || !DOMPurify.sanitize) {
499
+ return html
500
+ }
501
+ const result = DOMPurify.sanitize(html, PURIFY_CONFIG)
502
+ return typeof result === 'string' ? result : String(result)
503
+ } catch {
504
+ // DOM 환경 부재 또는 dompurify 초기화 실패 — 입력 그대로 통과
505
+ // (브라우저에선 정상 동작 — Node 테스트에선 sanitize 의 효과를 직접 검증)
506
+ return html
507
+ }
508
+ }
@@ -160,16 +160,16 @@ function applyOp(board: any, op: BoardEditOp): any {
160
160
  case 'add':
161
161
  return { ...board, components: [...(board.components || []), op.component] }
162
162
  case 'remove':
163
+ // 자식 (group/container 안) refid 도 매칭 — deep search.
163
164
  return {
164
165
  ...board,
165
- components: (board.components || []).filter((c: any) => c?.refid !== op.refid)
166
+ components: removeComponentDeep(board.components || [], op.refid)
166
167
  }
167
168
  case 'modify':
169
+ // 자식 refid 도 매칭 — group/container 안의 컴포넌트 수정 가능.
168
170
  return {
169
171
  ...board,
170
- components: (board.components || []).map((c: any) =>
171
- c?.refid === op.refid ? mergeComponent(c, op.patch) : c
172
- )
172
+ components: modifyComponentDeep(board.components || [], op.refid, op.patch)
173
173
  }
174
174
  case 'modifyBoard': {
175
175
  const patch = { ...(op.patch || {}) }
@@ -207,13 +207,15 @@ export function computeInverseOp(board: any, op: BoardEditOp): BoardEditOp | nul
207
207
  return null
208
208
 
209
209
  case 'remove': {
210
- const target = components.find((c: any) => c?.refid === op.refid)
210
+ // 자식 refid 매칭 (deep search)
211
+ const target = findComponentDeep(components, op.refid)
211
212
  if (!target) return null
212
213
  return { op: 'add', component: JSON.parse(JSON.stringify(target)) }
213
214
  }
214
215
 
215
216
  case 'modify': {
216
- const target = components.find((c: any) => c?.refid === op.refid)
217
+ // 자식 refid 매칭 (deep search)
218
+ const target = findComponentDeep(components, op.refid)
217
219
  if (!target) return null
218
220
  const oldValues: any = {}
219
221
  for (const k of Object.keys(op.patch || {})) {
@@ -310,3 +312,63 @@ function deepMerge(a: any, b: any): any {
310
312
  function isPlainObject(v: any): boolean {
311
313
  return v !== null && typeof v === 'object' && !Array.isArray(v)
312
314
  }
315
+
316
+ // ── 자식 컴포넌트 (group/container 안) 까지 매칭 — deep search ─
317
+ // server/service/apply-patch.ts 와 동일 로직. drift test 가 동등성 보장.
318
+
319
+ function findComponentDeep(components: any[], refid: number): any | undefined {
320
+ for (const c of components) {
321
+ if (!c) continue
322
+ if (c.refid === refid) return c
323
+ if (Array.isArray(c.components) && c.components.length > 0) {
324
+ const sub = findComponentDeep(c.components, refid)
325
+ if (sub) return sub
326
+ }
327
+ }
328
+ return undefined
329
+ }
330
+
331
+ function modifyComponentDeep(components: any[], refid: number, patch: any): any[] {
332
+ let changed = false
333
+ const out = components.map(c => {
334
+ if (!c) return c
335
+ if (c.refid === refid) {
336
+ changed = true
337
+ return mergeComponent(c, patch)
338
+ }
339
+ if (Array.isArray(c.components) && c.components.length > 0) {
340
+ const updated = modifyComponentDeep(c.components, refid, patch)
341
+ if (updated !== c.components) {
342
+ changed = true
343
+ return { ...c, components: updated }
344
+ }
345
+ }
346
+ return c
347
+ })
348
+ return changed ? out : components
349
+ }
350
+
351
+ function removeComponentDeep(components: any[], refid: number): any[] {
352
+ let changed = false
353
+ const out: any[] = []
354
+ for (const c of components) {
355
+ if (!c) {
356
+ out.push(c)
357
+ continue
358
+ }
359
+ if (c.refid === refid) {
360
+ changed = true
361
+ continue
362
+ }
363
+ if (Array.isArray(c.components) && c.components.length > 0) {
364
+ const updated = removeComponentDeep(c.components, refid)
365
+ if (updated !== c.components) {
366
+ changed = true
367
+ out.push({ ...c, components: updated })
368
+ continue
369
+ }
370
+ }
371
+ out.push(c)
372
+ }
373
+ return changed ? out : components
374
+ }
@@ -49,4 +49,12 @@ export declare function injectMentionUserIds(text: string, picks: ReadonlyArray<
49
49
  userId: string;
50
50
  }> | null | undefined): string;
51
51
  export declare function renderMarkdown(text: string): string;
52
+ /**
53
+ * DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.
54
+ *
55
+ * 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.
56
+ * Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback
57
+ * (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).
58
+ */
59
+ export declare function sanitizeHtml(html: string): string;
52
60
  export {};
@@ -1,8 +1,18 @@
1
1
  /**
2
2
  * Board AI 채팅 메시지의 Markdown 렌더링.
3
3
  *
4
- * 보안:
5
- * - raw HTML 차단 (renderer.html → ''), 위험 link 스킴 (javascript:, data: 등) 차단.
4
+ * 보안 (defense-in-depth):
5
+ * 1. raw HTML 차단 — marked 의 renderer.html → '' block-level HTML 무력화.
6
+ * 2. 위험 link 스킴 차단 — javascript: / data: / vbscript: 등 거부.
7
+ * 3. DOMPurify 후처리 — marked 출력 전체를 sanitize. 우회되는 inline HTML
8
+ * (이미지 onerror, span style="javascript:..." 등) 까지 차단.
9
+ *
10
+ * 1·2 만으로도 표면이 좁지만, 다음의 *알려진 우회 경로* 가 존재하므로 3 이 필수:
11
+ * - marked 가 토큰화 실패한 일부 입력 (특히 코드블록 안 백틱 escape 미스) 에서
12
+ * 생 HTML 이 escape 안 된 채 흘러나갈 수 있음
13
+ * - mention span (`<span class="mention" ...>`) 이 우리 손으로 만드는 element 라
14
+ * allowlist 기반 정화 필요
15
+ * - 멀티유저 환경 (다른 사용자 메시지 echo) 시 표면 ↑
6
16
  *
7
17
  * CJK 보강:
8
18
  * - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와
@@ -13,17 +23,63 @@
13
23
  * - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은
14
24
  * escapeHtml 로 항상 escape).
15
25
  */
26
+ import DOMPurify from 'dompurify';
16
27
  import { marked } from 'marked';
28
+ /**
29
+ * DOMPurify 정화 정책.
30
+ *
31
+ * 우리가 markdown 출력에서 의도적으로 사용하는 element / attribute 만 명시 허용:
32
+ * - 표준 markdown elements (p, h1~6, ul/ol/li, strong/em, a, img, code, pre, blockquote, hr, br, table 계열)
33
+ * - mention span (class + data-refid + data-userid)
34
+ *
35
+ * 그 외는 모두 strip — 이벤트 핸들러 (onerror/onclick), inline style, javascript:
36
+ * URL 등 자동 차단. 우리 구조에 없는 element (script/iframe/object/embed 등) 도 모두 제거.
37
+ */
38
+ const PURIFY_CONFIG = {
39
+ ALLOWED_TAGS: [
40
+ 'p', 'br', 'hr',
41
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
42
+ 'strong', 'em', 'del', 's', 'u',
43
+ 'ul', 'ol', 'li',
44
+ 'a', 'img',
45
+ 'code', 'pre',
46
+ 'blockquote',
47
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
48
+ 'span'
49
+ ],
50
+ ALLOWED_ATTR: [
51
+ 'href', 'title', 'alt', 'src', 'class',
52
+ 'data-refid', 'data-userid'
53
+ ],
54
+ // javascript: / data: / vbscript: 등 위험 스킴 자동 차단
55
+ ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
56
+ // target="_blank" 같이 우리가 명시하는 link 외엔 추가 attr 안 붙이도록
57
+ ADD_ATTR: ['target', 'rel'],
58
+ // raw HTML 입력은 marked.renderer.html → '' 로 이미 막혀있지만,
59
+ // dompurify 가 전체 input 을 한 번 더 정화 — 우회 차단
60
+ KEEP_CONTENT: true
61
+ };
17
62
  const markdownRenderer = new marked.Renderer();
18
63
  markdownRenderer.html = () => '';
64
+ const DANGEROUS_SCHEME_RE = /^(javascript|data|vbscript|file):/i;
19
65
  const origLink = markdownRenderer.link.bind(markdownRenderer);
20
66
  markdownRenderer.link = (token) => {
21
67
  const href = typeof token?.href === 'string' ? token.href.trim() : '';
22
- if (/^(javascript|data|vbscript):/i.test(href)) {
68
+ if (DANGEROUS_SCHEME_RE.test(href)) {
23
69
  return token?.text ?? '';
24
70
  }
25
71
  return origLink(token);
26
72
  };
73
+ // 이미지 renderer — src scheme 검사 + alt 의 inline HTML escape
74
+ const origImage = markdownRenderer.image.bind(markdownRenderer);
75
+ markdownRenderer.image = (token) => {
76
+ const src = typeof token?.href === 'string' ? token.href.trim() : '';
77
+ if (DANGEROUS_SCHEME_RE.test(src)) {
78
+ // 위험 src — 이미지 자체 제거. alt 텍스트는 escape 해서 평문 노출
79
+ return escapeHtml(token?.text ?? '');
80
+ }
81
+ return origImage(token);
82
+ };
27
83
  marked.setOptions({
28
84
  gfm: true,
29
85
  breaks: true,
@@ -334,10 +390,33 @@ export function renderMarkdown(text) {
334
390
  return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>');
335
391
  }
336
392
  // 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.
337
- return restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps);
393
+ const restored = restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps);
394
+ // 4) DOMPurify 후처리 — defense-in-depth. marked / 우리 mention span 둘 다 한 번 더 정화.
395
+ return sanitizeHtml(restored);
338
396
  }
339
397
  catch {
340
398
  return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>');
341
399
  }
342
400
  }
401
+ /**
402
+ * DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.
403
+ *
404
+ * 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.
405
+ * Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback
406
+ * (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).
407
+ */
408
+ export function sanitizeHtml(html) {
409
+ try {
410
+ if (typeof window === 'undefined' || !DOMPurify.sanitize) {
411
+ return html;
412
+ }
413
+ const result = DOMPurify.sanitize(html, PURIFY_CONFIG);
414
+ return typeof result === 'string' ? result : String(result);
415
+ }
416
+ catch {
417
+ // DOM 환경 부재 또는 dompurify 초기화 실패 — 입력 그대로 통과
418
+ // (브라우저에선 정상 동작 — Node 테스트에선 sanitize 의 효과를 직접 검증)
419
+ return html;
420
+ }
421
+ }
343
422
  //# sourceMappingURL=markdown.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"markdown.js","sourceRoot":"","sources":["../../../client/components/markdown.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Board AI 채팅 메시지의 Markdown 렌더링.\n *\n * 보안:\n * - raw HTML 차단 (renderer.html → ''), 위험 link 스킴 (javascript:, data: 등) 차단.\n *\n * CJK 보강:\n * - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와\n * ** 강조 마커 사이에 구두점이 끼면 마커가 닫히지 않는 케이스가 발생한다.\n * 예) \"**'rect'**의\" → 닫는 ** 가 [구두점] + [CJK] 사이에 위치하므로 right-flanking 실패\n * - 이런 케이스는 marked 에 넘기기 전에 자체적으로 추출 → 자리표시자로 치환 → marked\n * 수행 → 후처리에서 <strong>...</strong> 로 복원하는 방식으로 처리한다.\n * - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은\n * escapeHtml 로 항상 escape).\n */\nimport { marked } from 'marked'\n\nconst markdownRenderer = new marked.Renderer()\nmarkdownRenderer.html = () => ''\nconst origLink = markdownRenderer.link.bind(markdownRenderer)\nmarkdownRenderer.link = (token: any) => {\n const href = typeof token?.href === 'string' ? token.href.trim() : ''\n if (/^(javascript|data|vbscript):/i.test(href)) {\n return token?.text ?? ''\n }\n return origLink(token)\n}\n\nmarked.setOptions({\n gfm: true,\n breaks: true,\n pedantic: false,\n renderer: markdownRenderer,\n async: false\n} as any)\n\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;')\n}\n\n// CJK 문자 범위: Hiragana, Katakana, CJK Unified Ideographs (Ext A 포함), Hangul, 전각/반각\nconst CJK_RE = /[぀-ヿ㐀-䶿一-鿿가-힯＀-￯]/\nconst PUNCT_RE = /\\p{P}/u\n\n// marked 가 절대 변형하지 않는 토큰 (영숫자만 사용)\nconst PLACEHOLDER_PREFIX = 'BAICJKSTRONG'\nconst PLACEHOLDER_SUFFIX = 'ENDMARK'\nconst MENTION_PLACEHOLDER_PREFIX = 'BAIMENTION'\nconst MENTION_PLACEHOLDER_SUFFIX = 'ENDMENTION'\n\n// mention 토큰에 허용되는 문자 — alphanumeric + 한글/일본/중국어. 공백/구두점에서 끊김.\nconst MENTION_WORD_RE = /[\\w\\p{Script=Hangul}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}]/u\n\ninterface ExtractResult {\n processed: string\n stored: string[]\n}\n\ninterface MentionExtractResult {\n processed: string\n stored: StoredMention[]\n}\n\n/**\n * `**X**` 패턴 중 CJK + 구두점 인접 때문에 marked 의 flanking 규칙에서 누락되는\n * 케이스를 추출. 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않는다.\n *\n * 검출 기준 (CommonMark right-flanking 실패 ↔ left-flanking 실패):\n * - 닫는 ** 직전 문자가 구두점 AND 직후 문자가 CJK letter\n * - 여는 ** 직전 문자가 CJK letter AND 직후 문자가 구두점\n */\nexport function extractCjkStrongs(text: string): ExtractResult {\n const stored: string[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processLine(line, stored, () => outLines[outLines.length - 1] ?? ''))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\nfunction processLine(line: string, stored: string[], prevLine: () => string): string {\n let i = 0\n let result = ''\n while (i < line.length) {\n const ch = line[i]\n\n if (ch === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n\n if (ch === '*' && line[i + 1] === '*') {\n const close = findCloseStrong(line, i + 2)\n if (close === -1) {\n result += '**'\n i += 2\n continue\n }\n const inner = line.slice(i + 2, close)\n if (inner.length === 0) {\n result += '**'\n i += 2\n continue\n }\n const before = result.length > 0 ? result.slice(-1) : prevLine().slice(-1)\n const after = close + 2 < line.length ? line[close + 2] : ''\n if (needsCjkFix(before, after, inner)) {\n stored.push(inner)\n result += `${PLACEHOLDER_PREFIX}${stored.length - 1}${PLACEHOLDER_SUFFIX}`\n } else {\n result += line.slice(i, close + 2)\n }\n i = close + 2\n continue\n }\n\n result += ch\n i++\n }\n return result\n}\n\nfunction findCloseStrong(line: string, start: number): number {\n let i = start\n while (i < line.length - 1) {\n if (line[i] === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) return -1\n i = end + 1\n continue\n }\n if (line[i] === '*' && line[i + 1] === '*') return i\n i++\n }\n return -1\n}\n\nfunction needsCjkFix(before: string, after: string, inner: string): boolean {\n const innerStart = inner[0]\n const innerEnd = inner[inner.length - 1]\n return (\n (before !== '' && CJK_RE.test(before) && PUNCT_RE.test(innerStart)) ||\n (after !== '' && CJK_RE.test(after) && PUNCT_RE.test(innerEnd))\n )\n}\n\nfunction restoreCjkStrongs(html: string, stored: string[]): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const token = `${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`\n const replacement = `<strong>${escapeHtml(stored[n])}</strong>`\n out = out.split(token).join(replacement)\n }\n return out\n}\n\n/**\n * `#token` mention 패턴을 추출 → placeholder 로 치환.\n *\n * 활성 조건 (chat textarea 의 extractMentionContext 와 일관):\n * - `#` 직전이 문자열 시작 또는 word char 가 아님 (공백/구두점/줄 시작)\n * - `#` 직후가 word char (영숫자/한글/일본/중국어)\n * - 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않음\n *\n * Markdown ATX 헤딩 (`# Title`) 과 충돌하지 않음 — 헤딩은 `#` 다음에 공백이 오므로\n * 본 정규식 (직후가 word char) 매칭 안 됨.\n */\nexport function extractMentions(text: string): MentionExtractResult {\n const stored: StoredMention[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processMentionLine(line, stored))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\n/** 추출된 mention — trigger 와 함께 보존해 restore 시 trigger 별 다른 span 생성. */\ninterface StoredMention {\n trigger: '#' | '@'\n token: string\n}\n\nfunction processMentionLine(line: string, stored: StoredMention[]): string {\n let result = ''\n let i = 0\n while (i < line.length) {\n const ch = line[i]\n if (ch === '`') {\n // 인라인 코드 — 닫는 backtick 까지 그대로 통과\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n if (ch === '#' || ch === '@') {\n const prev = i > 0 ? line[i - 1] : ''\n const next = line[i + 1] ?? ''\n if (\n (prev === '' || !MENTION_WORD_RE.test(prev)) &&\n next !== '' &&\n MENTION_WORD_RE.test(next)\n ) {\n // 토큰 끝 위치 찾기\n let j = i + 1\n while (j < line.length && MENTION_WORD_RE.test(line[j])) j++\n const token = line.slice(i + 1, j)\n stored.push({ trigger: ch as '#' | '@', token })\n result += `${MENTION_PLACEHOLDER_PREFIX}${stored.length - 1}${MENTION_PLACEHOLDER_SUFFIX}`\n i = j\n continue\n }\n }\n result += ch\n i++\n }\n return result\n}\n\n/**\n * placeholder 를 mention span 으로 복원. trigger 별로 다른 span:\n * - `#component` → `<span class=\"mention\" data-refid=\"N\">#token</span>`\n * - `@user` → `<span class=\"user-mention\" data-userid=\"UUID\">@token</span>`\n * 매핑 없으면 data-* 속성 없이 단순 span.\n */\nfunction restoreMentions(\n html: string,\n stored: StoredMention[],\n maps: InlineMentionMaps\n): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const placeholder = `${MENTION_PLACEHOLDER_PREFIX}${n}${MENTION_PLACEHOLDER_SUFFIX}`\n const { trigger, token } = stored[n]\n let replacement: string\n if (trigger === '#') {\n const refid = maps.refidByToken.get(token)\n const attr =\n typeof refid === 'number' && Number.isFinite(refid)\n ? ` data-refid=\"${refid}\"`\n : ''\n replacement = `<span class=\"mention\"${attr}>#${escapeHtml(token)}</span>`\n } else {\n const userId = maps.userIdByToken.get(token)\n const attr = userId ? ` data-userid=\"${escapeHtml(userId)}\"` : ''\n replacement = `<span class=\"user-mention\"${attr}>@${escapeHtml(token)}</span>`\n }\n out = out.split(placeholder).join(replacement)\n }\n return out\n}\n\n/**\n * 인라인 마커 정규식 — `{key:value}` 형태. 현재 사용:\n * - `{refid:N}` — `#component` 멘션\n * - `{userId:UUID}` — `@user` 멘션\n * 향후 추가 종류 (agentId / threadId / 외부 시스템 id 등) 도 동일 형식.\n */\nconst MENTION_MARKER_RE = /\\{[a-zA-Z][a-zA-Z0-9]*:[^}\\s]+\\}/g\n\nconst WORD_CLASS =\n '\\\\w\\\\p{Script=Hangul}\\\\p{Script=Hiragana}\\\\p{Script=Katakana}\\\\p{Script=Han}'\n\n/**\n * 본문에서 모든 `{key:value}` 마커 제거 — textarea 편집 / LLM 입력 / 평문 표출 등.\n * 토큰 (#component / @user) 자체는 보존.\n */\nexport function stripMentionRefids(text: string | null | undefined): string {\n if (!text) return ''\n return text.replace(MENTION_MARKER_RE, '')\n}\n\ninterface InlineMentionMaps {\n /** trigger='#' 의 token → refid (component) */\n refidByToken: Map<string, number>\n /** trigger='@' 의 token → userId (user) */\n userIdByToken: Map<string, string>\n}\n\n/**\n * `#token{refid:N}` / `@user{userId:UUID}` 마커를 추출해 매핑 + 마커 제거된 본문 반환.\n * 렌더링 직전에 호출 — 마킹 정보 분리 후 markdown 처리에 깨끗한 텍스트만.\n */\nfunction extractInlineMentions(text: string): {\n cleaned: string\n maps: InlineMentionMaps\n} {\n const refidByToken = new Map<string, number>()\n const userIdByToken = new Map<string, string>()\n\n const componentRe = new RegExp(\n `(^|[^${WORD_CLASS}])#([${WORD_CLASS}-]+)\\\\{refid:(\\\\d+)\\\\}`,\n 'gu'\n )\n const userRe = new RegExp(\n `(^|[^${WORD_CLASS}])@([${WORD_CLASS}-]+)\\\\{userId:([^}\\\\s]+)\\\\}`,\n 'gu'\n )\n\n let cleaned = text.replace(componentRe, (_m, leading, token, refidStr) => {\n const refid = Number(refidStr)\n if (Number.isFinite(refid) && !refidByToken.has(token)) refidByToken.set(token, refid)\n return `${leading}#${token}`\n })\n cleaned = cleaned.replace(userRe, (_m, leading, token, userId) => {\n if (!userIdByToken.has(token)) userIdByToken.set(token, userId)\n return `${leading}@${token}`\n })\n return { cleaned, maps: { refidByToken, userIdByToken } }\n}\n\n/**\n * 일반화된 inline marker 주입.\n * injectInlineMarkers(text, '#', 'refid', [{token, value: 35}])\n * injectInlineMarkers(text, '@', 'userId', [{token, value: 'uuid'}])\n */\nfunction injectInlineMarkers(\n text: string,\n trigger: string,\n markerKey: string,\n picks: ReadonlyArray<{ token: string; value: string | number }> | null | undefined\n): string {\n if (!text || !picks || picks.length === 0) return text ?? ''\n const map = new Map<string, string | number>()\n for (const p of picks) map.set(p.token, p.value)\n const escapedTrig = trigger.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const re = new RegExp(\n `(^|[^${WORD_CLASS}])(${escapedTrig})([${WORD_CLASS}-]+)(\\\\{${markerKey}:[^}\\\\s]+\\\\})?`,\n 'gu'\n )\n return text.replace(re, (match, leading, trig, token, existingMarker) => {\n if (existingMarker) return match\n const value = map.get(token)\n if (value === undefined) return match\n return `${leading}${trig}${token}{${markerKey}:${value}}`\n })\n}\n\n/** `#component` 멘션 picks → `{refid:N}` 마커 주입 (호환 유지). */\nexport function injectMentionRefids(\n text: string,\n picks: ReadonlyArray<{ token: string; refid: number }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '#',\n 'refid',\n picks?.map(p => ({ token: p.token, value: p.refid }))\n )\n}\n\n/** `@user` 멘션 picks → `{userId:UUID}` 마커 주입. */\nexport function injectMentionUserIds(\n text: string,\n picks: ReadonlyArray<{ token: string; userId: string }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '@',\n 'userId',\n picks?.map(p => ({ token: p.token, value: p.userId }))\n )\n}\n\nexport function renderMarkdown(text: string): string {\n if (!text) return ''\n try {\n // 0) inline 마커 추출 (`{refid:N}` / `{userId:UUID}`) → 매핑 분리 + 본문 정화\n const { cleaned, maps } = extractInlineMentions(text)\n // 1) mention placeholder — `#abc` / `@abc` 가 marked 의 ATX 헤딩 / 이메일 등에\n // 휩쓸리지 않도록 영숫자 placeholder 로 치환.\n const m = extractMentions(cleaned)\n // 2) CJK strong placeholder\n const c = extractCjkStrongs(m.processed)\n const out = marked.parse(c.processed)\n if (typeof out !== 'string') {\n // marked 가 Promise 를 반환했다면 (async lexer 등) 안전한 fallback\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n // 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.\n return restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps)\n } catch {\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n}\n"]}
1
+ {"version":3,"file":"markdown.js","sourceRoot":"","sources":["../../../client/components/markdown.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Board AI 채팅 메시지의 Markdown 렌더링.\n *\n * 보안 (defense-in-depth):\n * 1. raw HTML 차단 — marked 의 renderer.html → '' 로 block-level HTML 무력화.\n * 2. 위험 link 스킴 차단 — javascript: / data: / vbscript: 등 거부.\n * 3. DOMPurify 후처리 — marked 출력 전체를 sanitize. 우회되는 inline HTML\n * (이미지 onerror, span style=\"javascript:...\" 등) 까지 차단.\n *\n * 1·2 만으로도 표면이 좁지만, 다음의 *알려진 우회 경로* 가 존재하므로 3 이 필수:\n * - marked 가 토큰화 실패한 일부 입력 (특히 코드블록 안 백틱 escape 미스) 에서\n * 생 HTML 이 escape 안 된 채 흘러나갈 수 있음\n * - mention span (`<span class=\"mention\" ...>`) 이 우리 손으로 만드는 element 라\n * allowlist 기반 정화 필요\n * - 멀티유저 환경 (다른 사용자 메시지 echo) 시 표면 ↑\n *\n * CJK 보강:\n * - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와\n * ** 강조 마커 사이에 구두점이 끼면 마커가 닫히지 않는 케이스가 발생한다.\n * 예) \"**'rect'**의\" → 닫는 ** 가 [구두점] + [CJK] 사이에 위치하므로 right-flanking 실패\n * - 이런 케이스는 marked 에 넘기기 전에 자체적으로 추출 → 자리표시자로 치환 → marked\n * 수행 → 후처리에서 <strong>...</strong> 로 복원하는 방식으로 처리한다.\n * - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은\n * escapeHtml 로 항상 escape).\n */\nimport DOMPurify from 'dompurify'\nimport { marked } from 'marked'\n\n/**\n * DOMPurify 정화 정책.\n *\n * 우리가 markdown 출력에서 의도적으로 사용하는 element / attribute 만 명시 허용:\n * - 표준 markdown elements (p, h1~6, ul/ol/li, strong/em, a, img, code, pre, blockquote, hr, br, table 계열)\n * - mention span (class + data-refid + data-userid)\n *\n * 그 외는 모두 strip — 이벤트 핸들러 (onerror/onclick), inline style, javascript:\n * URL 등 자동 차단. 우리 구조에 없는 element (script/iframe/object/embed 등) 도 모두 제거.\n */\nconst PURIFY_CONFIG: any = {\n ALLOWED_TAGS: [\n 'p', 'br', 'hr',\n 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',\n 'strong', 'em', 'del', 's', 'u',\n 'ul', 'ol', 'li',\n 'a', 'img',\n 'code', 'pre',\n 'blockquote',\n 'table', 'thead', 'tbody', 'tr', 'th', 'td',\n 'span'\n ],\n ALLOWED_ATTR: [\n 'href', 'title', 'alt', 'src', 'class',\n 'data-refid', 'data-userid'\n ],\n // javascript: / data: / vbscript: 등 위험 스킴 자동 차단\n ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i,\n // target=\"_blank\" 같이 우리가 명시하는 link 외엔 추가 attr 안 붙이도록\n ADD_ATTR: ['target', 'rel'],\n // raw HTML 입력은 marked.renderer.html → '' 로 이미 막혀있지만,\n // dompurify 가 전체 input 을 한 번 더 정화 — 우회 차단\n KEEP_CONTENT: true\n}\n\nconst markdownRenderer = new marked.Renderer()\nmarkdownRenderer.html = () => ''\n\nconst DANGEROUS_SCHEME_RE = /^(javascript|data|vbscript|file):/i\n\nconst origLink = markdownRenderer.link.bind(markdownRenderer)\nmarkdownRenderer.link = (token: any) => {\n const href = typeof token?.href === 'string' ? token.href.trim() : ''\n if (DANGEROUS_SCHEME_RE.test(href)) {\n return token?.text ?? ''\n }\n return origLink(token)\n}\n\n// 이미지 renderer — src scheme 검사 + alt 의 inline HTML escape\nconst origImage = markdownRenderer.image.bind(markdownRenderer)\nmarkdownRenderer.image = (token: any) => {\n const src = typeof token?.href === 'string' ? token.href.trim() : ''\n if (DANGEROUS_SCHEME_RE.test(src)) {\n // 위험 src — 이미지 자체 제거. alt 텍스트는 escape 해서 평문 노출\n return escapeHtml(token?.text ?? '')\n }\n return origImage(token)\n}\n\nmarked.setOptions({\n gfm: true,\n breaks: true,\n pedantic: false,\n renderer: markdownRenderer,\n async: false\n} as any)\n\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;')\n}\n\n// CJK 문자 범위: Hiragana, Katakana, CJK Unified Ideographs (Ext A 포함), Hangul, 전각/반각\nconst CJK_RE = /[぀-ヿ㐀-䶿一-鿿가-힯＀-￯]/\nconst PUNCT_RE = /\\p{P}/u\n\n// marked 가 절대 변형하지 않는 토큰 (영숫자만 사용)\nconst PLACEHOLDER_PREFIX = 'BAICJKSTRONG'\nconst PLACEHOLDER_SUFFIX = 'ENDMARK'\nconst MENTION_PLACEHOLDER_PREFIX = 'BAIMENTION'\nconst MENTION_PLACEHOLDER_SUFFIX = 'ENDMENTION'\n\n// mention 토큰에 허용되는 문자 — alphanumeric + 한글/일본/중국어. 공백/구두점에서 끊김.\nconst MENTION_WORD_RE = /[\\w\\p{Script=Hangul}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}]/u\n\ninterface ExtractResult {\n processed: string\n stored: string[]\n}\n\ninterface MentionExtractResult {\n processed: string\n stored: StoredMention[]\n}\n\n/**\n * `**X**` 패턴 중 CJK + 구두점 인접 때문에 marked 의 flanking 규칙에서 누락되는\n * 케이스를 추출. 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않는다.\n *\n * 검출 기준 (CommonMark right-flanking 실패 ↔ left-flanking 실패):\n * - 닫는 ** 직전 문자가 구두점 AND 직후 문자가 CJK letter\n * - 여는 ** 직전 문자가 CJK letter AND 직후 문자가 구두점\n */\nexport function extractCjkStrongs(text: string): ExtractResult {\n const stored: string[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processLine(line, stored, () => outLines[outLines.length - 1] ?? ''))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\nfunction processLine(line: string, stored: string[], prevLine: () => string): string {\n let i = 0\n let result = ''\n while (i < line.length) {\n const ch = line[i]\n\n if (ch === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n\n if (ch === '*' && line[i + 1] === '*') {\n const close = findCloseStrong(line, i + 2)\n if (close === -1) {\n result += '**'\n i += 2\n continue\n }\n const inner = line.slice(i + 2, close)\n if (inner.length === 0) {\n result += '**'\n i += 2\n continue\n }\n const before = result.length > 0 ? result.slice(-1) : prevLine().slice(-1)\n const after = close + 2 < line.length ? line[close + 2] : ''\n if (needsCjkFix(before, after, inner)) {\n stored.push(inner)\n result += `${PLACEHOLDER_PREFIX}${stored.length - 1}${PLACEHOLDER_SUFFIX}`\n } else {\n result += line.slice(i, close + 2)\n }\n i = close + 2\n continue\n }\n\n result += ch\n i++\n }\n return result\n}\n\nfunction findCloseStrong(line: string, start: number): number {\n let i = start\n while (i < line.length - 1) {\n if (line[i] === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) return -1\n i = end + 1\n continue\n }\n if (line[i] === '*' && line[i + 1] === '*') return i\n i++\n }\n return -1\n}\n\nfunction needsCjkFix(before: string, after: string, inner: string): boolean {\n const innerStart = inner[0]\n const innerEnd = inner[inner.length - 1]\n return (\n (before !== '' && CJK_RE.test(before) && PUNCT_RE.test(innerStart)) ||\n (after !== '' && CJK_RE.test(after) && PUNCT_RE.test(innerEnd))\n )\n}\n\nfunction restoreCjkStrongs(html: string, stored: string[]): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const token = `${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`\n const replacement = `<strong>${escapeHtml(stored[n])}</strong>`\n out = out.split(token).join(replacement)\n }\n return out\n}\n\n/**\n * `#token` mention 패턴을 추출 → placeholder 로 치환.\n *\n * 활성 조건 (chat textarea 의 extractMentionContext 와 일관):\n * - `#` 직전이 문자열 시작 또는 word char 가 아님 (공백/구두점/줄 시작)\n * - `#` 직후가 word char (영숫자/한글/일본/중국어)\n * - 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않음\n *\n * Markdown ATX 헤딩 (`# Title`) 과 충돌하지 않음 — 헤딩은 `#` 다음에 공백이 오므로\n * 본 정규식 (직후가 word char) 매칭 안 됨.\n */\nexport function extractMentions(text: string): MentionExtractResult {\n const stored: StoredMention[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processMentionLine(line, stored))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\n/** 추출된 mention — trigger 와 함께 보존해 restore 시 trigger 별 다른 span 생성. */\ninterface StoredMention {\n trigger: '#' | '@'\n token: string\n}\n\nfunction processMentionLine(line: string, stored: StoredMention[]): string {\n let result = ''\n let i = 0\n while (i < line.length) {\n const ch = line[i]\n if (ch === '`') {\n // 인라인 코드 — 닫는 backtick 까지 그대로 통과\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n if (ch === '#' || ch === '@') {\n const prev = i > 0 ? line[i - 1] : ''\n const next = line[i + 1] ?? ''\n if (\n (prev === '' || !MENTION_WORD_RE.test(prev)) &&\n next !== '' &&\n MENTION_WORD_RE.test(next)\n ) {\n // 토큰 끝 위치 찾기\n let j = i + 1\n while (j < line.length && MENTION_WORD_RE.test(line[j])) j++\n const token = line.slice(i + 1, j)\n stored.push({ trigger: ch as '#' | '@', token })\n result += `${MENTION_PLACEHOLDER_PREFIX}${stored.length - 1}${MENTION_PLACEHOLDER_SUFFIX}`\n i = j\n continue\n }\n }\n result += ch\n i++\n }\n return result\n}\n\n/**\n * placeholder 를 mention span 으로 복원. trigger 별로 다른 span:\n * - `#component` → `<span class=\"mention\" data-refid=\"N\">#token</span>`\n * - `@user` → `<span class=\"user-mention\" data-userid=\"UUID\">@token</span>`\n * 매핑 없으면 data-* 속성 없이 단순 span.\n */\nfunction restoreMentions(\n html: string,\n stored: StoredMention[],\n maps: InlineMentionMaps\n): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const placeholder = `${MENTION_PLACEHOLDER_PREFIX}${n}${MENTION_PLACEHOLDER_SUFFIX}`\n const { trigger, token } = stored[n]\n let replacement: string\n if (trigger === '#') {\n const refid = maps.refidByToken.get(token)\n const attr =\n typeof refid === 'number' && Number.isFinite(refid)\n ? ` data-refid=\"${refid}\"`\n : ''\n replacement = `<span class=\"mention\"${attr}>#${escapeHtml(token)}</span>`\n } else {\n const userId = maps.userIdByToken.get(token)\n const attr = userId ? ` data-userid=\"${escapeHtml(userId)}\"` : ''\n replacement = `<span class=\"user-mention\"${attr}>@${escapeHtml(token)}</span>`\n }\n out = out.split(placeholder).join(replacement)\n }\n return out\n}\n\n/**\n * 인라인 마커 정규식 — `{key:value}` 형태. 현재 사용:\n * - `{refid:N}` — `#component` 멘션\n * - `{userId:UUID}` — `@user` 멘션\n * 향후 추가 종류 (agentId / threadId / 외부 시스템 id 등) 도 동일 형식.\n */\nconst MENTION_MARKER_RE = /\\{[a-zA-Z][a-zA-Z0-9]*:[^}\\s]+\\}/g\n\nconst WORD_CLASS =\n '\\\\w\\\\p{Script=Hangul}\\\\p{Script=Hiragana}\\\\p{Script=Katakana}\\\\p{Script=Han}'\n\n/**\n * 본문에서 모든 `{key:value}` 마커 제거 — textarea 편집 / LLM 입력 / 평문 표출 등.\n * 토큰 (#component / @user) 자체는 보존.\n */\nexport function stripMentionRefids(text: string | null | undefined): string {\n if (!text) return ''\n return text.replace(MENTION_MARKER_RE, '')\n}\n\ninterface InlineMentionMaps {\n /** trigger='#' 의 token → refid (component) */\n refidByToken: Map<string, number>\n /** trigger='@' 의 token → userId (user) */\n userIdByToken: Map<string, string>\n}\n\n/**\n * `#token{refid:N}` / `@user{userId:UUID}` 마커를 추출해 매핑 + 마커 제거된 본문 반환.\n * 렌더링 직전에 호출 — 마킹 정보 분리 후 markdown 처리에 깨끗한 텍스트만.\n */\nfunction extractInlineMentions(text: string): {\n cleaned: string\n maps: InlineMentionMaps\n} {\n const refidByToken = new Map<string, number>()\n const userIdByToken = new Map<string, string>()\n\n const componentRe = new RegExp(\n `(^|[^${WORD_CLASS}])#([${WORD_CLASS}-]+)\\\\{refid:(\\\\d+)\\\\}`,\n 'gu'\n )\n const userRe = new RegExp(\n `(^|[^${WORD_CLASS}])@([${WORD_CLASS}-]+)\\\\{userId:([^}\\\\s]+)\\\\}`,\n 'gu'\n )\n\n let cleaned = text.replace(componentRe, (_m, leading, token, refidStr) => {\n const refid = Number(refidStr)\n if (Number.isFinite(refid) && !refidByToken.has(token)) refidByToken.set(token, refid)\n return `${leading}#${token}`\n })\n cleaned = cleaned.replace(userRe, (_m, leading, token, userId) => {\n if (!userIdByToken.has(token)) userIdByToken.set(token, userId)\n return `${leading}@${token}`\n })\n return { cleaned, maps: { refidByToken, userIdByToken } }\n}\n\n/**\n * 일반화된 inline marker 주입.\n * injectInlineMarkers(text, '#', 'refid', [{token, value: 35}])\n * injectInlineMarkers(text, '@', 'userId', [{token, value: 'uuid'}])\n */\nfunction injectInlineMarkers(\n text: string,\n trigger: string,\n markerKey: string,\n picks: ReadonlyArray<{ token: string; value: string | number }> | null | undefined\n): string {\n if (!text || !picks || picks.length === 0) return text ?? ''\n const map = new Map<string, string | number>()\n for (const p of picks) map.set(p.token, p.value)\n const escapedTrig = trigger.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const re = new RegExp(\n `(^|[^${WORD_CLASS}])(${escapedTrig})([${WORD_CLASS}-]+)(\\\\{${markerKey}:[^}\\\\s]+\\\\})?`,\n 'gu'\n )\n return text.replace(re, (match, leading, trig, token, existingMarker) => {\n if (existingMarker) return match\n const value = map.get(token)\n if (value === undefined) return match\n return `${leading}${trig}${token}{${markerKey}:${value}}`\n })\n}\n\n/** `#component` 멘션 picks → `{refid:N}` 마커 주입 (호환 유지). */\nexport function injectMentionRefids(\n text: string,\n picks: ReadonlyArray<{ token: string; refid: number }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '#',\n 'refid',\n picks?.map(p => ({ token: p.token, value: p.refid }))\n )\n}\n\n/** `@user` 멘션 picks → `{userId:UUID}` 마커 주입. */\nexport function injectMentionUserIds(\n text: string,\n picks: ReadonlyArray<{ token: string; userId: string }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '@',\n 'userId',\n picks?.map(p => ({ token: p.token, value: p.userId }))\n )\n}\n\nexport function renderMarkdown(text: string): string {\n if (!text) return ''\n try {\n // 0) inline 마커 추출 (`{refid:N}` / `{userId:UUID}`) → 매핑 분리 + 본문 정화\n const { cleaned, maps } = extractInlineMentions(text)\n // 1) mention placeholder — `#abc` / `@abc` 가 marked 의 ATX 헤딩 / 이메일 등에\n // 휩쓸리지 않도록 영숫자 placeholder 로 치환.\n const m = extractMentions(cleaned)\n // 2) CJK strong placeholder\n const c = extractCjkStrongs(m.processed)\n const out = marked.parse(c.processed)\n if (typeof out !== 'string') {\n // marked 가 Promise 를 반환했다면 (async lexer 등) 안전한 fallback\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n // 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.\n const restored = restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps)\n // 4) DOMPurify 후처리 — defense-in-depth. marked / 우리 mention span 둘 다 한 번 더 정화.\n return sanitizeHtml(restored)\n } catch {\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n}\n\n/**\n * DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.\n *\n * 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.\n * Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback\n * (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).\n */\nexport function sanitizeHtml(html: string): string {\n try {\n if (typeof window === 'undefined' || !DOMPurify.sanitize) {\n return html\n }\n const result = DOMPurify.sanitize(html, PURIFY_CONFIG)\n return typeof result === 'string' ? result : String(result)\n } catch {\n // DOM 환경 부재 또는 dompurify 초기화 실패 — 입력 그대로 통과\n // (브라우저에선 정상 동작 — Node 테스트에선 sanitize 의 효과를 직접 검증)\n return html\n }\n}\n"]}