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
438 changes: 438 additions & 0 deletions PRPs/PRP-reliability-E3-safe-uuid-non-secure-context.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,19 @@ export default defineConfig([
'react-refresh/only-export-components': 'off',
},
},
// #332 — crypto.randomUUID is undefined outside secure contexts (plain-HTTP LAN).
{
files: ['**/*.{ts,tsx}'],
rules: {
'no-restricted-properties': [
'error',
{
object: 'crypto',
property: 'randomUUID',
message:
'crypto.randomUUID is undefined outside secure contexts (plain-HTTP LAN origins). Use safeRandomUUID() from @/lib/uuid-utils instead. (#332)',
},
],
},
},
])
24 changes: 24 additions & 0 deletions frontend/src/components/demo/RunHistoryStrip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const STORAGE_KEY = 'forecastlab.showcase.runs.v1'
afterEach(() => {
cleanup()
window.localStorage.clear()
vi.unstubAllGlobals()
})

beforeEach(() => {
Expand Down Expand Up @@ -84,6 +85,29 @@ describe('RunHistoryStrip', () => {
)
})

it('appends a history entry without crashing when crypto.randomUUID is unavailable (#332)', () => {
// Non-secure contexts (plain-HTTP LAN origins) expose getRandomValues but
// NOT randomUUID. jsdom's crypto has randomUUID, so the LAN shape must be
// stubbed explicitly — an unstubbed render passes even against the bug.
const realGetRandomValues = globalThis.crypto.getRandomValues.bind(globalThis.crypto)
vi.stubGlobal('crypto', {
getRandomValues: realGetRandomValues,
} as unknown as Crypto)

const { container } = render(
<RunHistoryStrip onReplay={() => {}} summary={summary} scenario="showcase_rich" />,
)

expect(container.textContent).toContain('showcase_rich')
const stored = window.localStorage.getItem(STORAGE_KEY)
expect(stored).not.toBeNull()
const items = JSON.parse(stored!)
expect(items).toHaveLength(1)
expect(items[0].id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
)
})

it('Clear button empties history + localStorage', () => {
const { container } = render(
<RunHistoryStrip onReplay={() => {}} summary={summary} scenario="demo_minimal" />,
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/demo/RunHistoryStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { safeRandomUUID } from '@/lib/uuid-utils'
import type { DemoRunRequest, ScenarioPreset } from '@/types/api'
import type { DemoSummary } from '@/hooks/use-demo-pipeline'

Expand Down Expand Up @@ -72,7 +73,7 @@ export function RunHistoryStrip({ onReplay, summary, scenario }: RunHistoryStrip
setItems((prev) =>
[
{
id: crypto.randomUUID(),
id: safeRandomUUID(),
runId: summary.winningRunId,
timestamp: new Date().toISOString(),
scenario,
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/lib/uuid-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { safeRandomUUID } from './uuid-utils'

const V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/

afterEach(() => {
vi.unstubAllGlobals()
})

describe('safeRandomUUID', () => {
it('delegates to crypto.randomUUID when available', () => {
vi.stubGlobal('crypto', {
randomUUID: vi.fn(() => 'fixed-uuid'),
} as unknown as Crypto)

expect(safeRandomUUID()).toBe('fixed-uuid')
})

it('falls back to getRandomValues v4 when randomUUID is missing (LAN-HTTP shape)', () => {
// The real plain-HTTP LAN shape: getRandomValues present, randomUUID absent (#332).
vi.stubGlobal('crypto', {
getRandomValues: globalThis.crypto.getRandomValues.bind(globalThis.crypto),
} as unknown as Crypto)

const first = safeRandomUUID()
const second = safeRandomUUID()
expect(first).toMatch(V4_REGEX)
expect(second).toMatch(V4_REGEX)
expect(first).not.toBe(second)
})

it('falls back to Math.random v4 when crypto is entirely absent', () => {
vi.stubGlobal('crypto', undefined)

const first = safeRandomUUID()
const second = safeRandomUUID()
expect(first).toMatch(V4_REGEX)
expect(second).toMatch(V4_REGEX)
expect(first).not.toBe(second)
})
})
31 changes: 31 additions & 0 deletions frontend/src/lib/uuid-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* #332 — crypto.randomUUID() exists only in secure contexts (HTTPS or localhost).
* On a plain-HTTP LAN origin (the showcase dogfood setup) it is undefined and a direct
* call TypeErrors. crypto.getRandomValues is NOT secure-context-gated, so the fallback
* keeps cryptographically-strong entropy; Math.random is a last resort for environments
* with no Web Crypto at all (ids here are React keys / history ids, not security tokens).
*/
export function safeRandomUUID(): string {
// eslint-disable-next-line no-restricted-properties -- feature-detecting the restricted member
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
// eslint-disable-next-line no-restricted-properties -- the one sanctioned call site
return crypto.randomUUID()
}
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 // version 4
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 // variant 10xx
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
// No Web Crypto at all — uniqueness only, not cryptographic strength.
let uuid = ''
for (let i = 0; i < 36; i++) {
if (i === 8 || i === 13 || i === 18 || i === 23) uuid += '-'
else if (i === 14) uuid += '4'
else if (i === 19) uuid += (((Math.random() * 4) | 0) | 8).toString(16) // 8,9,a,b
else uuid += ((Math.random() * 16) | 0).toString(16)
}
return uuid
}