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
12 changes: 12 additions & 0 deletions frontend/src/utils/serverRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ export function serverDetailPath(name: string, tab?: string): string {
return tab ? `${base}?tab=${encodeURIComponent(tab)}` : base
}

// MCP-2125 (#643 Defect B): scan ids embed the raw upstream server name, so an
// official-registry server whose name contains '/' (e.g.
// "com.pulsemcp/google-flights") yields a scan id like
// "scan-com.pulsemcp/google-flights-1781284446323229000". The scan-report route
// is a single `:jobId` segment, so the id MUST be percent-encoded — otherwise
// the '/' splits the path and the link falls through to the catch-all 404.
// vue-router decodes the param back to the original id on read (same class as
// serverDetailPath above / MCP-1112).
export function scanReportPath(jobId: string): string {
return `/security/scans/${encodeURIComponent(jobId)}`
}

// serverDisplayName prefers the registry-provided human-friendly `title` over
// the raw reverse-DNS `name` identifier (e.g. "io.github.owner/repo"). The
// `name` remains the stable key used for API calls and routing; only the
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/views/Security.vue
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@
<td>
<router-link
v-if="scan.status === 'completed'"
:to="`/security/scans/${encodeURIComponent(scan.id)}`"
:to="scanReportPath(scan.id)"
class="link link-primary text-sm whitespace-nowrap"
>
Details →
Expand Down Expand Up @@ -448,6 +448,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import api from '@/services/api'
import { refreshSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
import { useSystemStore } from '@/stores/system'
import { scanReportPath } from '@/utils/serverRoute'

const systemStore = useSystemStore()

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/views/ServerDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,7 @@

<!-- Action buttons -->
<div class="flex gap-3">
<router-link v-if="scanReport.job_id" :to="`/security/scans/${scanReport.job_id}`" class="btn btn-primary btn-sm">
<router-link v-if="scanReport.job_id" :to="scanReportPath(scanReport.job_id)" class="btn btn-primary btn-sm">
View Full Report &rarr;
</router-link>
</div>
Expand Down Expand Up @@ -1223,7 +1223,7 @@ import type { Hint } from '@/components/CollapsibleHintsPanel.vue'
import type { Server, Tool, ToolApproval, SecurityScanReport } from '@/types'
import api from '@/services/api'
import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
import { serverDisplayName } from '@/utils/serverRoute'
import { serverDisplayName, scanReportPath } from '@/utils/serverRoute'
import { selectQuarantinedTools } from '@/utils/toolQuarantine'
import { oauthSignInState } from '@/utils/health'
import { computeToolDiffSections } from '@/utils/toolDiff'
Expand Down
43 changes: 43 additions & 0 deletions frontend/tests/unit/scan-report-route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { createRouter, createWebHistory } from 'vue-router'
import { scanReportPath } from '@/utils/serverRoute'

// MCP-2125 (Defect B of MCP-2123): scan ids embed the raw upstream server name,
// so official-registry servers whose names contain '/' (e.g.
// "com.pulsemcp/google-flights") produce a scan id like
// "scan-com.pulsemcp/google-flights-1781284446323229000". The scan-report route
// is a single `:jobId` segment, so an unencoded '/' splits the path and falls
// through to the catch-all 404. The id MUST be percent-encoded; vue-router v4
// decodes the param back on read (same class as MCP-1112 / serverDetailPath).

const SLASH_SCAN_ID = 'scan-com.pulsemcp/google-flights-1781284446323229000'

describe('scanReportPath (MCP-2125)', () => {
it('percent-encodes a "/"-containing scan id into a single path segment', () => {
expect(scanReportPath(SLASH_SCAN_ID)).toBe(
'/security/scans/scan-com.pulsemcp%2Fgoogle-flights-1781284446323229000'
)
})

it('leaves a plain scan id untouched (no "/" to encode)', () => {
expect(scanReportPath('scan-github-123')).toBe('/security/scans/scan-github-123')
})
})

describe('scan-report route round-trip (MCP-2125)', () => {
it('decodes the encoded "/" back into the jobId param (no 404)', async () => {
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/security/scans/:jobId', name: 'scan-report', component: { template: '<div/>' } },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: { template: '<div>404</div>' } },
],
})
await router.push(scanReportPath(SLASH_SCAN_ID))
await router.isReady()
// It must match scan-report (NOT the catch-all 404)...
expect(router.currentRoute.value.name).toBe('scan-report')
// ...and the param must be decoded back to the original scan id.
expect(router.currentRoute.value.params.jobId).toBe(SLASH_SCAN_ID)
})
})
Loading