Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ docs/phases/
docs/specs/
PROJECT-STATUS.md
.worktrees/
.spacebot-dev/
20 changes: 20 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,26 @@ export const api = {
}
return response.json() as Promise<Types.OpenAiOAuthBrowserStatusResponse>;
},
startCopilotOAuthBrowser: async (params: { model: string }) => {
const response = await fetch(`${getApiBase()}/providers/github-copilot/browser-oauth/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: params.model }),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<Types.CopilotOAuthBrowserStartResponse>;
},
copilotOAuthBrowserStatus: async (state: string) => {
const response = await fetch(
`${getApiBase()}/providers/github-copilot/browser-oauth/status?state=${encodeURIComponent(state)}`,
);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<Types.CopilotOAuthBrowserStatusResponse>;
},
removeProvider: async (provider: string) => {
const response = await fetch(`${getApiBase()}/providers/${encodeURIComponent(provider)}`, {
method: "DELETE",
Expand Down
108 changes: 108 additions & 0 deletions interface/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/providers/github-copilot/browser-oauth/start": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["start_copilot_browser_oauth"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/providers/github-copilot/browser-oauth/status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["copilot_browser_oauth_status"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/providers/openai/browser-oauth/start": {
parameters: {
query?: never;
Expand Down Expand Up @@ -2253,6 +2285,22 @@ export interface components {
/** Format: float */
emergency_threshold?: number | null;
};
CopilotOAuthBrowserStartRequest: {
model: string;
};
CopilotOAuthBrowserStartResponse: {
message: string;
state?: string | null;
success: boolean;
user_code?: string | null;
verification_url?: string | null;
};
CopilotOAuthBrowserStatusResponse: {
done: boolean;
found: boolean;
message?: string | null;
success: boolean;
};
CortexChatDeleteThreadRequest: {
agent_id: string;
thread_id: string;
Expand Down Expand Up @@ -3097,6 +3145,7 @@ export interface components {
fireworks: boolean;
gemini: boolean;
github_copilot: boolean;
github_copilot_oauth: boolean;
groq: boolean;
kilo: boolean;
minimax: boolean;
Expand Down Expand Up @@ -7094,6 +7143,65 @@ export interface operations {
};
};
};
start_copilot_browser_oauth: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CopilotOAuthBrowserStartRequest"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CopilotOAuthBrowserStartResponse"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
copilot_browser_oauth_status: {
parameters: {
query: {
/** @description OAuth state parameter */
state: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CopilotOAuthBrowserStatusResponse"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
start_openai_browser_oauth: {
parameters: {
query?: never;
Expand Down
3 changes: 3 additions & 0 deletions interface/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,9 @@ export type OpenAiOAuthBrowserStartResponse =
components["schemas"]["OpenAiOAuthBrowserStartResponse"];
export type OpenAiOAuthBrowserStatusResponse =
components["schemas"]["OpenAiOAuthBrowserStatusResponse"];
export type CopilotOAuthBrowserStartRequest = components["schemas"]["CopilotOAuthBrowserStartRequest"];
export type CopilotOAuthBrowserStartResponse = components["schemas"]["CopilotOAuthBrowserStartResponse"];
export type CopilotOAuthBrowserStatusResponse = components["schemas"]["CopilotOAuthBrowserStatusResponse"];

// Models
export type ModelInfo = components["schemas"]["ModelInfo"];
Expand Down
29 changes: 18 additions & 11 deletions interface/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";

export function Markdown({
// Stable module-level references so ReactMarkdown never re-renders due to
// new array/object identities on every call.
const remarkPlugins = [remarkGfm];
const rehypePlugins = [rehypeRaw];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For react-markdown 10.1.0, what is the recommended secure setup when using rehype-raw, and is rehype-sanitize required to prevent XSS from untrusted markdown?

💡 Result:

For react-markdown 10.1.0, the recommended secure posture is:

  • Default (recommended for untrusted markdown): do not use rehype-raw. React-markdown “typically escapes HTML … because it is dangerous” and is “secure by default” as long as you don’t introduce unsafe plugins / components / urlTransform overrides. (github.com)
  • If you enable HTML with rehype-raw:
    • Treat it as trusted-only input or sanitize.
    • rehype-raw explicitly warns that improper use can enable XSS and says to either not combine it with user content or use rehype-sanitize. (app.unpkg.com)

Recommended secure setup when using rehype-raw

Use rehype-sanitize after rehype-raw (and after any other “unsafe” steps), and customize the schema as needed:

import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'

<Markdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>
  {markdown}
</Markdown>

React-markdown’s own security guidance: “To make sure the content is completely safe, even after what plugins do, use rehype-sanitize.” (github.com)
rehype-sanitize drops anything not explicitly allowed (defaults to a GitHub-like schema) and recommends sanitizing whenever you don’t completely trust authors/plugins. (github.com)

Is rehype-sanitize required to prevent XSS from untrusted markdown?

  • If you do not use rehype-raw (default behavior): not strictly required for “raw HTML XSS,” because HTML is escaped/ignored by default; the main risk becomes what you add (plugins/components/URL handling). (github.com)
  • If you do use rehype-raw with untrusted markdown: you should treat rehype-sanitize (or equivalent sanitization) as required to prevent XSS. (app.unpkg.com)

Citations:


🏁 Script executed:

cd interface && head -30 src/components/Markdown.tsx

Repository: spacedriveapp/spacebot

Length of output: 932


🏁 Script executed:

# Check for package.json to see react-markdown version
fd -t f package.json | head -5

Repository: spacedriveapp/spacebot

Length of output: 127


🏁 Script executed:

# Search for usages of the Markdown component to understand data sources
rg "Markdown\s*[<({]" src --type tsx --type ts -B 2 -A 2

Repository: spacedriveapp/spacebot

Length of output: 93


🏁 Script executed:

# Check if any sanitization is already in place elsewhere
rg -i "sanitize|xss|escape" src/components/Markdown.tsx

Repository: spacedriveapp/spacebot

Length of output: 133


🏁 Script executed:

cat -n interface/src/components/Markdown.tsx

Repository: spacedriveapp/spacebot

Length of output: 1241


🏁 Script executed:

cat interface/package.json | grep -A 2 -B 2 react-markdown

Repository: spacedriveapp/spacebot

Length of output: 204


🏁 Script executed:

rg "Markdown" interface/src --type-add 'tsx:*.tsx' -t tsx -B 1 -A 1 | head -40

Repository: spacedriveapp/spacebot

Length of output: 2559


🏁 Script executed:

# Search for where Markdown component receives content (item.conclusion, item.result, release.body)
rg "conclusion|release\.body|item\.result" interface/src --type-add 'tsx:*.tsx' -t tsx -B 2 -A 2

Repository: spacedriveapp/spacebot

Length of output: 2987


Add rehype-sanitize to prevent XSS from untrusted markdown sources.

Line 9 enables rehypeRaw without sanitization. The Markdown component processes untrusted external content (e.g., release.body from GitHub API in Settings.tsx) and AI-generated output (e.g., item.conclusion, item.result in ChannelDetail.tsx). Without sanitization, malicious HTML in these sources can execute arbitrary scripts. Add rehype-sanitize after rehypeRaw to safely parse raw HTML while blocking dangerous elements.

Fix
 import rehypeRaw from "rehype-raw";
+import rehypeSanitize from "rehype-sanitize";
 
 const remarkPlugins = [remarkGfm];
-const rehypePlugins = [rehypeRaw];
+const rehypePlugins = [rehypeRaw, rehypeSanitize];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/Markdown.tsx` at line 9, The Markdown component
currently uses rehypeRaw in the rehypePlugins array which allows raw HTML from
untrusted sources; update the rehypePlugins in Markdown.tsx to include
rehypeSanitize immediately after rehypeRaw (importing rehypeSanitize as needed)
so raw HTML is parsed but unsafe elements/attributes are stripped—ensure the
import for rehypeRaw remains and add the rehypeSanitize import and include it in
the rehypePlugins array.

const markdownComponents = {
a: ({ children, href, ...props }: React.ComponentPropsWithoutRef<"a">) => (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
),
};

export const Markdown = memo(function Markdown({
children,
className,
}: {
Expand All @@ -12,18 +25,12 @@ export function Markdown({
return (
<div className={className ? `markdown ${className}` : "markdown"}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
a: ({ children, href, ...props }) => (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
),
}}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{children}
</ReactMarkdown>
</div>
);
}
});
2 changes: 2 additions & 0 deletions interface/src/components/ModelSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const PROVIDER_LABELS: Record<string, string> = {
minimax: "MiniMax",
"minimax-cn": "MiniMax CN",
"github-copilot": "GitHub Copilot",
"github-copilot-oauth": "GitHub Copilot (OAuth)",
};

function formatContextWindow(tokens: number | null): string {
Expand Down Expand Up @@ -136,6 +137,7 @@ export function ModelSelect({
"openai",
"openai-chatgpt",
"github-copilot",
"github-copilot-oauth",
"ollama",
"deepseek",
"xai",
Expand Down
55 changes: 31 additions & 24 deletions interface/src/components/WebChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,25 +96,28 @@ function ThinkingIndicator() {
);
}

// Input owns its own state so keystrokes never trigger a re-render of the
// parent WebChatPanel (and therefore never re-render the message list).
function FloatingChatInput({
value,
onChange,
onSubmit,
onSend,
disabled,
agentId,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onSend: (message: string) => void;
disabled: boolean;
agentId: string;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [input, setInput] = useState("");

// Focus on mount.
useEffect(() => {
textareaRef.current?.focus({preventScroll: true});
}, []);

// Attach the height-adjustment listener once. Using the native "input" event
// avoids adding [value] to the dependency array, which previously caused a
// remove-add cycle (and a forced reflow) on every single keystroke.
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
Expand All @@ -130,12 +133,26 @@ function FloatingChatInput({
adjustHeight();
textarea.addEventListener("input", adjustHeight);
return () => textarea.removeEventListener("input", adjustHeight);
}, [value]);
}, []);

const handleSubmit = () => {
const trimmed = input.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setInput("");
// React's controlled value update doesn't fire a native "input" event,
// so reset the height directly after clearing.
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.overflowY = "hidden";
}
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSubmit();
handleSubmit();
}
};

Expand All @@ -146,8 +163,8 @@ function FloatingChatInput({
<div className="flex items-end gap-2 p-3">
<textarea
ref={textareaRef}
value={value}
onChange={(event) => onChange(event.target.value)}
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={
disabled ? "Waiting for response..." : `Message ${agentId}...`
Expand All @@ -159,8 +176,8 @@ function FloatingChatInput({
/>
<button
type="button"
onClick={onSubmit}
disabled={disabled || !value.trim()}
onClick={handleSubmit}
disabled={disabled || !input.trim()}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-white transition-all duration-150 hover:bg-accent-deep disabled:opacity-30 disabled:hover:bg-accent"
>
<svg
Expand Down Expand Up @@ -188,7 +205,6 @@ export function WebChatPanel({agentId}: WebChatPanelProps) {
const [activeConversationId, setActiveConversationId] = useState<string>(getPortalSessionId(agentId));
const {isSending, error, sendMessage} = usePortal(agentId, activeConversationId);
const {liveStates} = useLiveContext();
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const [showSettings, setShowSettings] = useState(false);
const [settings, setSettings] = useState<ConversationSettings>({});
Expand Down Expand Up @@ -290,13 +306,6 @@ export function WebChatPanel({agentId}: WebChatPanelProps) {
}
}, [timeline.length, isTyping, activeWorkers.length]);

const handleSubmit = () => {
const trimmed = input.trim();
if (!trimmed || isSending) return;
setInput("");
sendMessage(trimmed);
};

const saveSettingsMutation = useMutation({
mutationFn: async () => {
if (!activeConversationId) return;
Expand All @@ -322,7 +331,7 @@ export function WebChatPanel({agentId}: WebChatPanelProps) {
onRenameConversation={(id, title) => renameConversationMutation.mutate({ id, title })}
onArchiveConversation={(id, archived) => archiveConversationMutation.mutate({ id, archived })}
isLoading={conversationsLoading}
/>
/>

{/* Main Chat Area */}
<div className="relative flex flex-1 flex-col">
Expand Down Expand Up @@ -425,9 +434,7 @@ export function WebChatPanel({agentId}: WebChatPanelProps) {

{/* Floating input */}
<FloatingChatInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
onSend={sendMessage}
disabled={isSending || isTyping}
agentId={agentId}
/>
Expand Down
1 change: 1 addition & 0 deletions interface/src/lib/providerIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24
moonshot: Kimi, // Kimi is Moonshot AI's product brand
"github-copilot": GithubCopilot,
azure: OpenAI,
"github-copilot-oauth": GithubCopilot,
};

const IconComponent = iconMap[provider.toLowerCase()];
Expand Down
Loading