1
0
Fork 0

Restore remote Upgrade to Hub button web component

This commit is contained in:
Gina A. 2025-11-12 12:16:06 +01:00 committed by GitHub
parent 77b1282570
commit a01c73d506
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1642 additions and 303 deletions

View file

@ -1,12 +1,13 @@
import { waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { PUBLIC_KEY } from './constants'
import HubDashboard, { resetCache } from './HubDashboard'
import verifySignature from './workers/scriptVerification'
import { renderWithProviders } from 'utils/test'
import verifySignature from 'utils/workers/scriptVerification'
vi.mock('./workers/scriptVerification', () => ({
vi.mock('utils/workers/scriptVerification', () => ({
default: vi.fn(),
}))
@ -34,7 +35,6 @@ describe('HubDashboard demo', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock URL.createObjectURL
mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
globalThis.URL.createObjectURL = mockCreateObjectURL
})
@ -45,7 +45,6 @@ describe('HubDashboard demo', () => {
describe('without cache', () => {
beforeEach(() => {
// Reset cache before each test suites
resetCache()
})
@ -130,6 +129,7 @@ describe('HubDashboard demo', () => {
expect(mockVerifyScriptSignature).toHaveBeenCalledWith(
'https://assets.traefik.io/hub-ui-demo.js',
'https://assets.traefik.io/hub-ui-demo.js.sig',
PUBLIC_KEY,
)
})
})

View file

@ -3,7 +3,9 @@ import { useMemo, useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import verifySignature from './workers/scriptVerification'
import verifySignature from '../../utils/workers/scriptVerification'
import { PUBLIC_KEY } from './constants'
import { SpinnerLoader } from 'components/SpinnerLoader'
import { useIsDarkMode } from 'hooks/use-theme'
@ -42,7 +44,7 @@ const HubDashboard = ({ path }: { path: string }) => {
setVerificationInProgress(true)
try {
const { verified, scriptContent: content } = await verifySignature(SCRIPT_URL, `${SCRIPT_URL}.sig`)
const { verified, scriptContent: content } = await verifySignature(SCRIPT_URL, `${SCRIPT_URL}.sig`, PUBLIC_KEY)
if (!verified || !content) {
setScriptError(true)

View file

@ -0,0 +1 @@
export const PUBLIC_KEY = 'MCowBQYDK2VwAyEAWMBZ0pMBaL/s8gNXxpAPCIQ8bxjnuz6bQFwGYvjXDfg='

View file

@ -3,9 +3,10 @@ import { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useHubDemo } from './use-hub-demo'
import verifySignature from './workers/scriptVerification'
vi.mock('./workers/scriptVerification', () => ({
import verifySignature from 'utils/workers/scriptVerification'
vi.mock('utils/workers/scriptVerification', () => ({
default: vi.fn(),
}))

View file

@ -1,9 +1,11 @@
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { RouteObject } from 'react-router-dom'
import { PUBLIC_KEY } from './constants'
import HubDashboard from 'pages/hub-demo/HubDashboard'
import { ApiIcon, DashboardIcon, GatewayIcon, PortalIcon } from 'pages/hub-demo/icons'
import verifySignature from 'pages/hub-demo/workers/scriptVerification'
import verifySignature from 'utils/workers/scriptVerification'
const ROUTES_MANIFEST_URL = 'https://traefik.github.io/hub-ui-demo-app/config/routes.json'
@ -20,7 +22,11 @@ const useHubDemoRoutesManifest = (): HubDemo.Manifest | null => {
useEffect(() => {
const fetchManifest = async () => {
try {
const { verified, scriptContent } = await verifySignature(ROUTES_MANIFEST_URL, `${ROUTES_MANIFEST_URL}.sig`)
const { verified, scriptContent } = await verifySignature(
ROUTES_MANIFEST_URL,
`${ROUTES_MANIFEST_URL}.sig`,
PUBLIC_KEY,
)
if (!verified || !scriptContent) {
setManifest(null)

View file

@ -1,156 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import verifySignature from './scriptVerification'
describe('Script Signature Verification - Integration Tests', () => {
let fetchMock: ReturnType<typeof vi.fn>
const SCRIPT_URL = 'https://example.com/script.js'
const SIGNATURE_URL = 'https://example.com/script.js.sig'
const TEST_PUBLIC_KEY = 'MCowBQYDK2VwAyEAWH71OHphISjNK3mizCR/BawiDxc6IXT1vFHpBcxSIA0='
const VALID_SCRIPT = "console.log('Hello from verified script!');"
const VALID_SIGNATURE_HEX =
'04c90fcd35caaf3cf4582a2767345f8cd9f6519e1ce79ebaeedbe0d5f671d762d1aa8ec258831557e2de0e47f224883f84eb5a0f22ec18eb7b8c48de3096d000'
const CORRUPTED_SCRIPT = "console.log('Malicious code injected!');"
beforeEach(() => {
vi.clearAllMocks()
fetchMock = vi.fn()
globalThis.fetch = fetchMock
})
it('should verify a valid script with correct signature through real worker', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === SCRIPT_URL) {
return Promise.resolve(
new Response(VALID_SCRIPT, {
status: 200,
headers: { 'Content-Type': 'application/javascript' },
}),
)
}
if (url === SIGNATURE_URL) {
return Promise.resolve(
new Response(VALID_SIGNATURE_HEX, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
}),
)
}
return Promise.reject(new Error('Unexpected URL'))
})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, TEST_PUBLIC_KEY)
expect(fetchMock).toHaveBeenCalledWith(SCRIPT_URL)
expect(fetchMock).toHaveBeenCalledWith(SIGNATURE_URL)
expect(result.verified).toBe(true)
expect(result.scriptContent).toBeDefined()
}, 15000)
it('should reject a corrupted script with mismatched signature', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === SCRIPT_URL) {
return Promise.resolve(
new Response(CORRUPTED_SCRIPT, {
status: 200,
headers: { 'Content-Type': 'application/javascript' },
}),
)
}
if (url === SIGNATURE_URL) {
return Promise.resolve(
new Response(VALID_SIGNATURE_HEX, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
}),
)
}
return Promise.reject(new Error('Unexpected URL'))
})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, TEST_PUBLIC_KEY)
expect(fetchMock).toHaveBeenCalledWith(SCRIPT_URL)
expect(fetchMock).toHaveBeenCalledWith(SIGNATURE_URL)
expect(result.verified).toBe(false)
expect(result.scriptContent).toBeUndefined()
}, 15000)
it('should reject script with invalid signature format', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === SCRIPT_URL) {
return Promise.resolve(
new Response(VALID_SCRIPT, {
status: 200,
headers: { 'Content-Type': 'application/javascript' },
}),
)
}
if (url === SIGNATURE_URL) {
return Promise.resolve(
new Response('not-a-valid-signature', {
status: 200,
headers: { 'Content-Type': 'text/plain' },
}),
)
}
return Promise.reject(new Error('Unexpected URL'))
})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, TEST_PUBLIC_KEY)
expect(result.verified).toBe(false)
expect(result.scriptContent).toBeUndefined()
}, 15000)
it('should reject script with wrong public key', async () => {
const WRONG_PUBLIC_KEY = 'MCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='
fetchMock.mockImplementation((url: string) => {
if (url === SCRIPT_URL) {
return Promise.resolve(
new Response(VALID_SCRIPT, {
status: 200,
headers: { 'Content-Type': 'application/javascript' },
}),
)
}
if (url === SIGNATURE_URL) {
return Promise.resolve(
new Response(VALID_SIGNATURE_HEX, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
}),
)
}
return Promise.reject(new Error('Unexpected URL'))
})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, WRONG_PUBLIC_KEY)
expect(result.verified).toBe(false)
expect(result.scriptContent).toBeUndefined()
}, 15000)
it('should handle network failures when fetching script', async () => {
fetchMock.mockImplementation(() =>
Promise.resolve(
new Response(null, {
status: 404,
statusText: 'Not Found',
}),
),
)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, TEST_PUBLIC_KEY)
expect(result.verified).toBe(false)
expect(result.scriptContent).toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
}, 15000)
})

View file

@ -1,125 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import verifySignature from './scriptVerification'
class MockWorker {
onmessage: ((event: MessageEvent) => void) | null = null
onerror: ((error: ErrorEvent) => void) | null = null
postMessage = vi.fn()
terminate = vi.fn()
simulateMessage(data: unknown) {
if (this.onmessage) {
this.onmessage(new MessageEvent('message', { data }))
}
}
simulateError(error: Error) {
if (this.onerror) {
this.onerror(new ErrorEvent('error', { error, message: error.message }))
}
}
}
describe('verifySignature', () => {
let mockWorkerInstance: MockWorker
let originalWorker: typeof Worker
beforeEach(() => {
vi.clearAllMocks()
originalWorker = globalThis.Worker
mockWorkerInstance = new MockWorker()
globalThis.Worker = class extends EventTarget {
constructor() {
super()
return mockWorkerInstance as any
}
} as any
})
afterEach(() => {
globalThis.Worker = originalWorker
vi.restoreAllMocks()
})
it('should return true when verification succeeds', async () => {
const scriptPath = 'https://example.com/script.js'
const signaturePath = 'https://example.com/script.js.sig'
const promise = verifySignature(scriptPath, signaturePath)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockWorkerInstance.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
scriptUrl: scriptPath,
signatureUrl: signaturePath,
requestId: expect.any(String),
}),
)
const mockScriptContent = new ArrayBuffer(100)
mockWorkerInstance.simulateMessage({
success: true,
verified: true,
error: null,
scriptContent: mockScriptContent,
})
const result = await promise
expect(result).toEqual({ verified: true, scriptContent: mockScriptContent })
expect(mockWorkerInstance.terminate).toHaveBeenCalled()
})
it('should return false when verification fails', async () => {
const scriptPath = 'https://example.com/script.js'
const signaturePath = 'https://example.com/script.js.sig'
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const promise = verifySignature(scriptPath, signaturePath)
await new Promise((resolve) => setTimeout(resolve, 0))
mockWorkerInstance.simulateMessage({
success: false,
verified: false,
error: 'Signature verification failed',
})
const result = await promise
expect(result).toEqual({ verified: false })
expect(mockWorkerInstance.terminate).toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith('Worker verification failed:', 'Signature verification failed')
consoleErrorSpy.mockRestore()
})
it('should return false when worker throws an error', async () => {
const scriptPath = 'https://example.com/script.js'
const signaturePath = 'https://example.com/script.js.sig'
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const promise = verifySignature(scriptPath, signaturePath)
await new Promise((resolve) => setTimeout(resolve, 0))
// Simulate worker onerror event
const error = new Error('Worker crashed')
mockWorkerInstance.simulateError(error)
const result = await promise
expect(result).toEqual({ verified: false })
expect(mockWorkerInstance.terminate).toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith('Worker error:', expect.any(ErrorEvent))
consoleErrorSpy.mockRestore()
})
})

View file

@ -1,57 +0,0 @@
export interface VerificationResult {
verified: boolean
scriptContent?: ArrayBuffer
}
const PUBLIC_KEY = 'MCowBQYDK2VwAyEAWMBZ0pMBaL/s8gNXxpAPCIQ8bxjnuz6bQFwGYvjXDfg='
async function verifySignature(
contentPath: string,
signaturePath: string,
publicKey: string = PUBLIC_KEY,
): Promise<VerificationResult> {
return new Promise((resolve) => {
const requestId = Math.random().toString(36).substring(2)
const worker = new Worker(new URL('./scriptVerificationWorker.ts', import.meta.url), { type: 'module' })
const timeout = setTimeout(() => {
worker.terminate()
console.error('Script verification timeout')
resolve({ verified: false })
}, 30000)
worker.onmessage = (event) => {
clearTimeout(timeout)
worker.terminate()
const { success, verified, error, scriptContent } = event.data
if (!success) {
console.error('Worker verification failed:', error)
resolve({ verified: false })
return
}
resolve({
verified: verified === true,
scriptContent: verified ? scriptContent : undefined,
})
}
worker.onerror = (error) => {
clearTimeout(timeout)
worker.terminate()
console.error('Worker error:', error)
resolve({ verified: false })
}
worker.postMessage({
requestId,
scriptUrl: contentPath,
signatureUrl: signaturePath,
publicKey,
})
})
}
export default verifySignature

View file

@ -1,189 +0,0 @@
// Script verification worker
// Runs in isolated context for secure verification
import { verify } from '@noble/ed25519'
import * as ed25519 from '@noble/ed25519'
import { sha512 } from '@noble/hashes/sha2.js'
// Set up SHA-512 for @noble/ed25519 v3.x
ed25519.hashes.sha512 = sha512
ed25519.hashes.sha512Async = (m) => Promise.resolve(sha512(m))
function base64ToArrayBuffer(base64: string): ArrayBuffer {
try {
// @ts-expect-error - fromBase64 is not yet in all TypeScript lib definitions
const bytes = Uint8Array.fromBase64(base64)
return bytes.buffer
} catch {
// Fallback for browsers without Uint8Array.fromBase64()
const binaryString = atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes.buffer
}
}
function extractEd25519PublicKey(spkiBytes: Uint8Array): Uint8Array {
if (spkiBytes.length !== 44) {
throw new Error('Invalid SPKI length for Ed25519')
}
return spkiBytes.slice(-32)
}
async function importPublicKeyWebCrypto(publicKey: string): Promise<CryptoKey> {
const publicKeyBuffer = base64ToArrayBuffer(publicKey)
return await crypto.subtle.importKey(
'spki',
publicKeyBuffer,
{
name: 'Ed25519',
},
false,
['verify'],
)
}
async function verifyWithWebCrypto(
publicKey: string,
scriptBuffer: ArrayBuffer,
signatureBuffer: ArrayBuffer,
): Promise<boolean> {
try {
const cryptoPublicKey = await importPublicKeyWebCrypto(publicKey)
return await crypto.subtle.verify('Ed25519', cryptoPublicKey, signatureBuffer, scriptBuffer)
} catch (error) {
console.log('Web Crypto verification failed:', error instanceof Error ? error.message : 'Unknown error')
return false
}
}
function parseSignature(signatureBuffer: ArrayBuffer): Uint8Array {
const signatureBytes = new Uint8Array(signatureBuffer)
// If already 64 bytes, assume it's raw binary
if (signatureBytes.length === 64) {
return signatureBytes
}
// Try to parse as text (base64 or hex)
const signatureText = new TextDecoder().decode(signatureBytes).trim()
// base64 decoding
try {
const base64Decoded = new Uint8Array(base64ToArrayBuffer(signatureText))
if (base64Decoded.length === 64) {
return base64Decoded
}
} catch (e) {
console.error(e)
}
// hex decoding
if (signatureText.length === 128 && /^[0-9a-fA-F]+$/.test(signatureText)) {
try {
// @ts-expect-error - fromHex is not yet in all TypeScript lib definitions
return Uint8Array.fromHex(signatureText)
} catch {
// Fallback for browsers without Uint8Array.fromHex()
const hexDecoded = new Uint8Array(64)
for (let i = 0; i < 64; i++) {
hexDecoded[i] = parseInt(signatureText.slice(i * 2, i * 2 + 2), 16)
}
return hexDecoded
}
}
throw new Error(`Unable to parse signature format.`)
}
async function verifyWithNoble(
publicKey: string,
scriptBuffer: ArrayBuffer,
signatureBuffer: ArrayBuffer,
): Promise<boolean> {
try {
const publicKeySpki = new Uint8Array(base64ToArrayBuffer(publicKey))
const publicKeyRaw = extractEd25519PublicKey(publicKeySpki)
const scriptBytes = new Uint8Array(scriptBuffer)
const signatureBytes = parseSignature(signatureBuffer)
return verify(signatureBytes, scriptBytes, publicKeyRaw)
} catch (error) {
console.log('Noble verification failed:', error instanceof Error ? error.message : 'Unknown error')
return false
}
}
self.onmessage = async function (event) {
const { requestId, scriptUrl, signatureUrl, publicKey } = event.data
try {
const [scriptResponse, signatureResponse] = await Promise.all([fetch(scriptUrl), fetch(signatureUrl)])
if (!scriptResponse.ok || !signatureResponse.ok) {
self.postMessage({
requestId,
success: false,
verified: false,
error: `Failed to fetch files. Script: ${scriptResponse.status} ${scriptResponse.statusText}, Signature: ${signatureResponse.status} ${signatureResponse.statusText}`,
})
return
}
const [scriptBuffer, signatureBuffer] = await Promise.all([
scriptResponse.arrayBuffer(),
signatureResponse.arrayBuffer(),
])
// Try Web Crypto API first, fallback to Noble if it fails
let verified = await verifyWithWebCrypto(publicKey, scriptBuffer, signatureBuffer)
if (!verified) {
verified = await verifyWithNoble(publicKey, scriptBuffer, signatureBuffer)
}
// If verified, include script content to avoid re-downloading
let scriptContent: ArrayBuffer | undefined
if (verified) {
scriptContent = scriptBuffer
}
// Send message with transferable ArrayBuffer for efficiency
const message = {
requestId,
success: true,
verified,
scriptSize: scriptBuffer.byteLength,
signatureSize: signatureBuffer.byteLength,
scriptContent,
}
if (scriptContent) {
self.postMessage(message, { transfer: [scriptContent] })
} else {
self.postMessage(message)
}
} catch (error) {
console.error('[Worker] Verification error:', error)
self.postMessage({
requestId,
success: false,
verified: false,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
self.onerror = function (error) {
console.error('[Worker] Worker error:', error)
self.postMessage({
success: false,
verified: false,
error,
})
}