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
40 changes: 28 additions & 12 deletions lib-vuetify/link-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
// page reload into an in-SPA navigation.

export interface ResolvedLink {
/** false when the link is not an http(s) URL (javascript:, data:, malformed…): never follow it */
safe: boolean
/** true when the link points to another origin and should leave the SPA entirely */
external: boolean
/** in-app router path (base prefix stripped); only meaningful when !external */
Expand All @@ -17,16 +19,26 @@ export interface ResolvedLink {
url: string
}

const UNSAFE: ResolvedLink = { safe: false, external: false, path: '', url: '' }

export function resolveAgentLink (rawUrl: string, origin: string, base: string): ResolvedLink {
const parsed = new URL(rawUrl, origin)
if (parsed.origin !== origin) return { external: true, path: '', url: rawUrl }
let parsed: URL
try {
parsed = new URL(rawUrl, origin)
} catch {
return UNSAFE
}
// The href comes from model output (and whatever a page or a tool result made it write): a
// javascript: URL assigned to the host's location runs in the host page, with the user's session.
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return UNSAFE
if (parsed.origin !== origin) return { safe: true, external: true, path: '', url: parsed.href }

const baseNoTrailing = base.endsWith('/') ? base.slice(0, -1) : base
let pathname = parsed.pathname
if (baseNoTrailing && (pathname === baseNoTrailing || pathname.startsWith(baseNoTrailing + '/'))) {
pathname = pathname.slice(baseNoTrailing.length) || '/'
}
return { external: false, path: pathname + parsed.search + parsed.hash, url: parsed.href }
return { safe: true, external: false, path: pathname + parsed.search + parsed.hash, url: parsed.href }
}

/**
Expand All @@ -39,8 +51,14 @@ export interface AgentNavRouter {
}

export interface NavDecision {
/** true => navigate in-SPA via router.push(path); false => full page load to url */
spa: boolean
/**
* - spa: router.push(path)
* - page: full navigation of the host page to url (same origin only)
* - new-tab: open url in a new tab without an opener (another origin: the host page stays, and
* a link planted in the conversation cannot silently replace it with a look-alike)
* - ignore: not an http(s) URL, do nothing
*/
action: 'spa' | 'page' | 'new-tab' | 'ignore'
path: string
url: string
}
Expand All @@ -52,11 +70,9 @@ export interface NavDecision {
* useRouter() yields undefined) we degrade to a full navigation rather than crash.
*/
export function decideAgentNavigation (rawUrl: string, origin: string, router?: AgentNavRouter): NavDecision {
if (!router) {
const link = resolveAgentLink(rawUrl, origin, '')
return { spa: false, path: link.path, url: link.url }
}
const link = resolveAgentLink(rawUrl, origin, router.options.history.base)
const spa = !link.external && router.resolve(link.path).matched.length > 0
return { spa, path: link.path, url: link.url }
const link = resolveAgentLink(rawUrl, origin, router ? router.options.history.base : '')
if (!link.safe) return { action: 'ignore', path: '', url: '' }
if (link.external) return { action: 'new-tab', path: '', url: link.url }
const spa = !!router && router.resolve(link.path).matched.length > 0
return { action: spa ? 'spa' : 'page', path: link.path, url: link.url }
}
11 changes: 8 additions & 3 deletions lib-vuetify/useAgentChatBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,17 @@ export function createAgentChatBase (isOpen: Ref<boolean>, storageKey?: string)
// The link may be a full URL, a base-prefixed path, or an app-relative path that
// omits our base prefix (models often write those). Resolve against our router base
// and navigate in-SPA when it maps to a real route; otherwise fall back to a full
// navigation (external links, same-origin pages outside this app, or no router).
// navigation (same-origin pages outside this app, or no router). Another origin opens in
// a new tab, and anything but an http(s) URL is ignored.
const decision = decideAgentNavigation(msg.url, window.location.origin, chatRouter)
if (decision.spa && chatRouter) {
if (decision.action === 'spa' && chatRouter) {
chatRouter.push(decision.path)
} else {
} else if (decision.action === 'page') {
window.location.href = decision.url
} else if (decision.action === 'new-tab') {
window.open(decision.url, '_blank', 'noopener,noreferrer')
} else {
debug('ignored navigation to a non-http(s) link %s', msg.url)
}
} else if (msg.type === 'unread') {
if (!isOpen.value && msg.unread) {
Expand Down
29 changes: 21 additions & 8 deletions tests/features/chat-links/link-utils.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ test.describe('resolveAgentLink', () => {
test('keeps a full same-origin URL, stripping the base to a router path', () => {
assert.deepEqual(
resolveAgentLink('https://koumoul.com/data-fair/dataset/abc/table', ORIGIN, BASE),
{ external: false, path: '/dataset/abc/table', url: 'https://koumoul.com/data-fair/dataset/abc/table' }
{ safe: true, external: false, path: '/dataset/abc/table', url: 'https://koumoul.com/data-fair/dataset/abc/table' }
)
})

Expand All @@ -32,7 +32,7 @@ test.describe('resolveAgentLink', () => {
test('rescues an app-relative path that omits the base (leading slash)', () => {
assert.deepEqual(
resolveAgentLink('/dataset/abc/table', ORIGIN, BASE),
{ external: false, path: '/dataset/abc/table', url: 'https://koumoul.com/dataset/abc/table' }
{ safe: true, external: false, path: '/dataset/abc/table', url: 'https://koumoul.com/dataset/abc/table' }
)
})

Expand All @@ -53,39 +53,52 @@ test.describe('resolveAgentLink', () => {
test('flags a different origin as external (full navigation, no rewrite)', () => {
assert.deepEqual(
resolveAgentLink('https://example.org/some/page', ORIGIN, BASE),
{ external: true, path: '', url: 'https://example.org/some/page' }
{ safe: true, external: true, path: '', url: 'https://example.org/some/page' }
)
})

test('maps the base root to "/"', () => {
assert.equal(resolveAgentLink('https://koumoul.com/data-fair', ORIGIN, BASE).path, '/')
})

test('flags anything but an http(s) URL as unsafe', () => {
for (const raw of ['javascript:alert(document.cookie)', 'JavaScript:alert(1)', ' javascript:alert(1)', 'data:text/html,<script>alert(1)</script>', 'vbscript:x', 'http://[bad']) {
assert.equal(resolveAgentLink(raw, ORIGIN, BASE).safe, false, raw)
}
})
})

test.describe('decideAgentNavigation', () => {
test('navigates in-SPA when the link maps to a known route', () => {
assert.deepEqual(
decideAgentNavigation('/data-fair/dataset/abc/table', ORIGIN, stubRouter(BASE, true)),
{ spa: true, path: '/dataset/abc/table', url: 'https://koumoul.com/data-fair/dataset/abc/table' }
{ action: 'spa', path: '/dataset/abc/table', url: 'https://koumoul.com/data-fair/dataset/abc/table' }
)
})

test('falls back to full navigation when the route is unmatched', () => {
const d = decideAgentNavigation('/data-fair/unknown', ORIGIN, stubRouter(BASE, false))
assert.equal(d.spa, false)
assert.equal(d.action, 'page')
assert.equal(d.url, 'https://koumoul.com/data-fair/unknown')
})

test('falls back to full navigation for an external link', () => {
test('opens an external link in a new tab, never in place of the host page', () => {
const d = decideAgentNavigation('https://example.org/page', ORIGIN, stubRouter(BASE, true))
assert.deepEqual(d, { spa: false, path: '', url: 'https://example.org/page' })
assert.deepEqual(d, { action: 'new-tab', path: '', url: 'https://example.org/page' })
})

test('ignores a javascript: or data: link, with or without a router', () => {
for (const router of [stubRouter(BASE, true), undefined]) {
assert.equal(decideAgentNavigation('javascript:alert(1)', ORIGIN, router).action, 'ignore')
assert.equal(decideAgentNavigation('data:text/html,x', ORIGIN, router).action, 'ignore')
}
})

test('without a router, falls back to a full navigation instead of throwing', () => {
// Reproduces the crash: the singleton was created outside a setup context so
// useRouter() yielded undefined. The handler must degrade, not read .options of undefined.
const d = decideAgentNavigation('/data-fair/dataset/abc/table', ORIGIN, undefined)
assert.equal(d.spa, false)
assert.equal(d.action, 'page')
assert.equal(d.url, 'https://koumoul.com/data-fair/dataset/abc/table')
})
})
12 changes: 10 additions & 2 deletions ui/src/components/EvaluatorChat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,15 @@ const handleAbort = () => {
}

const onNavigate = (url: string) => {
// links inside evaluator messages open in a new tab (this page is not an iframe)
window.open(url, '_blank', 'noopener')
// links inside evaluator messages open in a new tab (this page is not an iframe), and only
// http(s) ones: the href is model output
let parsed: URL
try {
parsed = new URL(url, window.location.href)
} catch {
return
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return
window.open(parsed.href, '_blank', 'noopener,noreferrer')
}
</script>
Loading