From ba3e505314a41c5669aea4369e280c59f9d2ed95 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 8 Sep 2026 10:51:11 -0500 Subject: [PATCH 1/3] Preserve Svelte return URLs through login --- .../ClientApp/e2e/tests/authentication.e2e.ts | 34 +++++++++++ .../src/lib/features/auth/index.svelte.ts | 6 +- .../src/lib/features/auth/navigation.test.ts | 59 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/auth/navigation.test.ts diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts index e5a8df0f0a..5c6541b40a 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts @@ -50,3 +50,37 @@ test('user can recover from a failed login, restore the session, and log out', a await authenticationContext.close(); } }); + +test('login restores the full notification settings link and selected project', async ({ browser, e2eApi, e2eScenario, e2eSecondaryProject }) => { + const context = await browser.newContext({ baseURL: e2eApi.environment.appUrl, ignoreHTTPSErrors: true }); + const page = await context.newPage(); + const destination = `/next/account/notifications?project=${e2eSecondaryProject.projectId}&from=email%2Bnotification%26settings#project-notifications`; + + try { + await test.step('preserve the complete destination when authentication is required', async () => { + await page.goto(destination); + await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible(); + await expect.poll(() => new URL(page.url()).searchParams.get('redirect')).toBe(destination); + }); + + await test.step('return to the requested project after login', async () => { + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByPlaceholder('Enter password').fill(E2E_TEST_PASSWORD); + await page.getByRole('button', { exact: true, name: 'Login' }).click(); + + await expect(page).toHaveURL(new URL(destination, e2eApi.environment.appUrl).href); + await expect(page.getByRole('heading', { exact: true, name: 'Project Notifications' })).toBeVisible(); + await expect(page.getByRole('button', { exact: true, name: e2eSecondaryProject.projectName })).toBeVisible(); + }); + + await test.step('preserve the same destination after the session expires', async () => { + await page.evaluate(() => localStorage.setItem('satellizer_token', 'expired-navigation-test-token')); + await page.reload(); + + await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible(); + await expect.poll(() => new URL(page.url()).searchParams.get('redirect')).toBe(destination); + }); + } finally { + await context.close(); + } +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts index 66a3b1ea45..406b926252 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts @@ -115,8 +115,10 @@ export async function googleLogin(redirectUrl?: string, inviteToken?: null | str export async function gotoLogin() { const url = page.url; - const isAuthPath = url.pathname.startsWith('/next/login'); - const redirect = url.pathname === resolve('/') || isAuthPath ? resolve('/(auth)/login') : `${resolve('/(auth)/login')}?redirect=${url.pathname}`; + const loginPath = resolve('/(auth)/login'); + const isAuthPath = url.pathname === loginPath || url.pathname === `${loginPath}/`; + const returnUrl = `${url.pathname}${url.search}${url.hash}`; + const redirect = url.pathname === resolve('/') || isAuthPath ? loginPath : `${loginPath}?redirect=${encodeURIComponent(returnUrl)}`; await goto(redirect, { replaceState: true }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/navigation.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/navigation.test.ts new file mode 100644 index 0000000000..48908e93af --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/navigation.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { goto, page, paths } = vi.hoisted(() => ({ + goto: vi.fn(), + page: { url: new URL('https://localhost/next/') }, + paths: { base: '/next' } +})); + +vi.mock('$app/navigation', () => ({ goto })); +vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path.replace('/(auth)', '')}` })); +vi.mock('$app/state', () => ({ page })); +vi.mock('$env/dynamic/public', () => ({ env: {} })); +vi.mock('./api.svelte', () => ({})); +vi.mock('./state.svelte', () => ({ accessToken: { current: null } })); +vi.mock('./validators', () => ({ validateEmailAvailability: vi.fn() })); + +import { gotoLogin } from './index.svelte'; + +describe('gotoLogin', () => { + beforeEach(() => { + goto.mockReset(); + paths.base = '/next'; + }); + + it.each([ + '/next/account/verify?token=verification%2Btoken%26value', + '/next/account/notifications?project=project-id&from=email#project-notifications', + '/next/organization/organization-id/billing?changePlan=true#billing', + '/next/event?filter=message%3A%22A%26B%22&tag=one&tag=two#details', + '/next/stack/stack-id' + ])('preserves the complete return destination %s', async (destination) => { + page.url = new URL(destination, 'https://localhost'); + + await gotoLogin(); + + expect(goto).toHaveBeenCalledExactlyOnceWith(`/next/login?redirect=${encodeURIComponent(destination)}`, { replaceState: true }); + const loginUrl = new URL(goto.mock.calls[0]![0], page.url.origin); + expect(loginUrl.searchParams.get('redirect')).toBe(destination); + expect([...loginUrl.searchParams.keys()]).toEqual(['redirect']); + expect(loginUrl.hash).toBe(''); + }); + + it.each(['/next/', '/next/login', '/next/login/'])('does not nest a login redirect from %s', async (path) => { + page.url = new URL(path, 'https://localhost'); + + await gotoLogin(); + + expect(goto).toHaveBeenCalledExactlyOnceWith('/next/login', { replaceState: true }); + }); + + it('uses the configured login route when the app is hosted at root', async () => { + paths.base = ''; + page.url = new URL('https://localhost/login'); + + await gotoLogin(); + + expect(goto).toHaveBeenCalledExactlyOnceWith('/login', { replaceState: true }); + }); +}); From c35df0e74de5b0634f948b1e5e880450f6be47f2 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 8 Sep 2026 19:43:57 -0500 Subject: [PATCH 2/3] Resolve Svelte runtime paths through SvelteKit --- .../assistant/assistant-links.test.ts | 39 ++++++++++++++++++- .../lib/features/assistant/assistant-links.ts | 19 +++++++-- .../ClientApp/src/lib/telemetry/route.test.ts | 32 +++++++++++++++ .../ClientApp/src/lib/telemetry/route.ts | 6 ++- .../ClientApp/src/routes/(app)/+layout.svelte | 23 ++++++++--- 5 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.test.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts index 1549f912f0..2db8155a81 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts @@ -1,9 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const paths = vi.hoisted(() => ({ base: '/next' })); + +vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path}` })); +vi.mock('$app/state', () => ({ page: { url: new URL('https://exceptionless.local/') } })); import type { AssistantToolActivity } from './models'; import { addAssistantResourceLinks, normalizeAssistantUrl } from './assistant-links'; +beforeEach(() => { + paths.base = '/next'; +}); + function toolResult(items: unknown[]): AssistantToolActivity { return { arguments: '{}', @@ -30,9 +39,37 @@ describe('normalizeAssistantUrl', () => { const source = 'https://example.com/next/assets/chart.png'; expect(normalizeAssistantUrl(source, 'src')).toBe(source); }); + + it('only rewrites same-origin absolute links when the app is hosted at root', () => { + paths.base = ''; + + expect(normalizeAssistantUrl('https://exceptionless.local/stack/stack-id?mode=summary#event', 'href')).toBe('/stack/stack-id?mode=summary#event'); + expect(normalizeAssistantUrl('https://docs.exceptionless.com/product/errors', 'href')).toBe('https://docs.exceptionless.com/product/errors'); + expect(normalizeAssistantUrl('https://example.com/stack/stack-id', 'href')).toBe('https://example.com/stack/stack-id'); + expect(normalizeAssistantUrl('/stack/stack-id', 'href')).toBe('/stack/stack-id'); + }); + + it('does not confuse a similar prefix with the app base', () => { + expect(normalizeAssistantUrl('https://example.com/nextdoor/stack/stack-id', 'href')).toBe('https://example.com/nextdoor/stack/stack-id'); + }); }); describe('addAssistantResourceLinks', () => { + it('links root-hosted resources while preserving existing URLs and markdown', () => { + paths.base = ''; + const content = 'See /project/API and https://example.test/API, [API](/project/existing), and `API` before opening API.'; + + expect(addAssistantResourceLinks(content, [toolResult([{ name: 'API', webUrl: '/project/api?tab=settings#details' }])])).toBe( + 'See /project/API and https://example.test/API, [API](/project/existing), and `API` before opening [API](/project/api?tab=settings#details).' + ); + }); + + it.each(['https://example.com/stack/1', '//example.com/stack/1', '/\\example.com/stack/1'])('rejects external resource URL %s at root', (webUrl) => { + paths.base = ''; + + expect(addAssistantResourceLinks('Investigate this title.', [toolResult([{ title: 'this title', webUrl }])])).toBe('Investigate this title.'); + }); + it('links matching stack titles in tables and prose using tool-result web URLs', () => { const content = `| Type | Title | | --- | --- | diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts index bf18495e55..166a9dd582 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts @@ -1,3 +1,6 @@ +import { resolve } from '$app/paths'; +import { page } from '$app/state'; + import type { AssistantToolActivity } from './models'; interface AssistantResourceLink { @@ -32,7 +35,8 @@ export function normalizeAssistantUrl(url: string, key: string): string { try { const parsedUrl = new URL(url); - if (parsedUrl.pathname === '/next' || parsedUrl.pathname.startsWith('/next/')) { + // At the root, the pathname alone cannot distinguish app links from external links. + if (isAssistantPath(parsedUrl.pathname) && (resolve('/') !== '/' || parsedUrl.origin === page.url.origin)) { return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; } } catch { @@ -49,7 +53,7 @@ function collectAssistantResourceLink(value: unknown, urlsByLabel: Map(); urls.add(url); urlsByLabel.set(label, urls); @@ -226,6 +230,11 @@ function getAssistantResourceLinks(tools: AssistantToolActivity[]): AssistantRes .sort((left, right) => right.label.length - left.label.length); } +function isAssistantPath(value: string): boolean { + const root = resolve('/'); + return !value.startsWith('//') && !value.includes('\\') && (value === root.slice(0, -1) || value.startsWith(root)); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -237,8 +246,10 @@ function readNonEmptyString(record: Record, key: string): strin function replaceOutsideProtectedMarkdown(content: string, replace: (text: string) => string): string { const protectedMarkdown = - /(^(?:(?: {0,3}>[\t ]?)*(?: {4}|\t)[^\r\n]*(?:\r?\n|$))+|!?\[[^\]\n]*\]\s*\[[^\]\n]*\]|!?\[[^\]\n]*\]|[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}|https?:\/\/[^\s<]+|\/next(?:\/[^\s<]*)?)/gm; - const protectedRanges = [...content.matchAll(protectedMarkdown)].map((match) => ({ + /(^(?:(?: {0,3}>[\t ]?)*(?: {4}|\t)[^\r\n]*(?:\r?\n|$))+|!?\[[^\]\n]*\]\s*\[[^\]\n]*\]|!?\[[^\]\n]*\]|[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}|https?:\/\/[^\s<]+)/gm; + const root = resolve('/'); + const appPathPattern = root === '/' ? /\/[^\s<]*/g : new RegExp(`${escapeRegularExpression(root.slice(0, -1))}(?:/[^\\s<]*)?`, 'g'); + const protectedRanges = [...content.matchAll(protectedMarkdown), ...content.matchAll(appPathPattern)].map((match) => ({ end: match.index + match[0].length, start: match.index })); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.test.ts new file mode 100644 index 0000000000..5a73e92ad8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const paths = vi.hoisted(() => ({ base: '/next' })); + +vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path}` })); + +import { normalizePath } from './route'; + +describe('normalizePath', () => { + beforeEach(() => { + paths.base = '/next'; + }); + + it.each(['/next', ''])('normalizes telemetry under the app base %j', (base) => { + paths.base = base; + + expect(normalizePath(`${base}/stack/507f1f77bcf86cd799439011/`)).toBe('/stack/:id'); + expect(normalizePath(`${base}/event/123/`)).toBe('/event/:id'); + expect(normalizePath(`${base}/event/550e8400-e29b-41d4-a716-446655440000`)).toBe('/event/:id'); + expect(normalizePath(`${base}/`)).toBe('/'); + }); + + it('does not strip a partial base segment', () => { + expect(normalizePath('/nextdoor/event/123')).toBe('/nextdoor/event/:id'); + expect(normalizePath('/next')).toBe('/'); + }); + + it('preserves support for an explicit normalization base', () => { + expect(normalizePath('/custom/event/123', '/custom')).toBe('/event/:id'); + expect(normalizePath('/next/event/123', '')).toBe('/next/event/:id'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.ts b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.ts index 304f86b5de..e0aa1f8c7d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/telemetry/route.ts @@ -1,11 +1,13 @@ +import { resolve } from '$app/paths'; + const OBJECTID_SEGMENT_REGEX = /^[0-9a-f]{24}$/i; const UUID_SEGMENT_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const NUMERIC_SEGMENT_REGEX = /^\d+$/; -export function normalizePath(path: string, basePath = '/next'): string { +export function normalizePath(path: string, basePath = resolve('/').replace(/\/$/, '')): string { let normalized = path; - if (basePath && normalized.startsWith(basePath)) { + if (basePath && (normalized === basePath || normalized.startsWith(`${basePath}/`))) { normalized = normalized.slice(basePath.length); } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 6994b60722..e6c4e7f16b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -167,7 +167,7 @@ } const url = new URL(value, page.url.origin); - if (url.origin !== page.url.origin || url.pathname === assistantPageHref || !url.pathname.startsWith('/next/')) { + if (url.origin !== page.url.origin || url.pathname === assistantPageHref || !url.pathname.startsWith(resolve('/'))) { return undefined; } @@ -175,13 +175,26 @@ } function getAssistantPath(context: AssistantResourceContext | undefined, fallback: string): string { + if (context?.eventId && context.stackId) { + return resolve('/(app)/stack/[stackId=objectid]/event/[eventId=objectid]', { + eventId: encodeURIComponent(context.eventId), + stackId: encodeURIComponent(context.stackId) + }); + } + if (context?.eventId) { - return context.stackId - ? `/next/stack/${encodeURIComponent(context.stackId)}/event/${encodeURIComponent(context.eventId)}` - : `/next/event/${encodeURIComponent(context.eventId)}`; + return resolve('/(app)/event/[eventId=objectid]', { + eventId: encodeURIComponent(context.eventId) + }); + } + + if (context?.stackId) { + return resolve('/(app)/stack/[stackId=objectid]', { + stackId: encodeURIComponent(context.stackId) + }); } - return context?.stackId ? `/next/stack/${encodeURIComponent(context.stackId)}` : fallback; + return fallback; } async function openOrganizationSwitcher(): Promise { From 1ec0427ed55293405bec4cc19ea457e60ea3f20c Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Tue, 8 Sep 2026 19:46:26 -0500 Subject: [PATCH 3/3] Reject disguised external assistant resource URLs --- .../lib/features/assistant/assistant-links.test.ts | 13 ++++++++----- .../src/lib/features/assistant/assistant-links.ts | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts index 2db8155a81..233056f910 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts @@ -64,11 +64,14 @@ describe('addAssistantResourceLinks', () => { ); }); - it.each(['https://example.com/stack/1', '//example.com/stack/1', '/\\example.com/stack/1'])('rejects external resource URL %s at root', (webUrl) => { - paths.base = ''; - - expect(addAssistantResourceLinks('Investigate this title.', [toolResult([{ title: 'this title', webUrl }])])).toBe('Investigate this title.'); - }); + it.each(['https://example.com/stack/1', '//example.com/stack/1', '/\\example.com/stack/1', '/\t/example.com/stack/1'])( + 'rejects external resource URL %s at root', + (webUrl) => { + paths.base = ''; + + expect(addAssistantResourceLinks('Investigate this title.', [toolResult([{ title: 'this title', webUrl }])])).toBe('Investigate this title.'); + } + ); it('links matching stack titles in tables and prose using tool-result web URLs', () => { const content = `| Type | Title | diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts index 166a9dd582..c074b1626b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts @@ -232,7 +232,7 @@ function getAssistantResourceLinks(tools: AssistantToolActivity[]): AssistantRes function isAssistantPath(value: string): boolean { const root = resolve('/'); - return !value.startsWith('//') && !value.includes('\\') && (value === root.slice(0, -1) || value.startsWith(root)); + return !value.startsWith('//') && !/[\\\s]/.test(value) && (value === root.slice(0, -1) || value.startsWith(root)); } function isRecord(value: unknown): value is Record {