From 99e6c0b07388908c69ab3da16e2915821b6762b4 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 16:56:59 +0200 Subject: [PATCH] fix(web): generate sync ids on loopback HTTP --- CHANGELOG.md | 3 +++ web/src/api/sync.test.ts | 11 ++++++++++- web/src/api/sync.ts | 22 +++++++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf47ddb..f90213f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ - Made relocatable launchers resolve stable user-bin symlinks to their physical versioned bundle before loading matching binaries and helpers. +- Made the Web sync button generate cryptographic UUIDv4 request IDs on the + loopback HTTP hostname where `crypto.randomUUID()` is unavailable. + - Closed the native candidate warning gate with checked path and HTTP response bounds, strict GCC and Clang builds, full Clang ASan/UBSan validation, and a stable concurrent object-I/O MTP double. Rebuilt the desktop, current Android diff --git a/web/src/api/sync.test.ts b/web/src/api/sync.test.ts index 7c1e5a8..8a4eb63 100644 --- a/web/src/api/sync.test.ts +++ b/web/src/api/sync.test.ts @@ -1,8 +1,17 @@ import { beforeEach,describe,expect,it,vi } from 'vitest' -import { fetchSyncStatus,startSync,syncIsActive } from './sync' +import { fetchSyncStatus, newRequestId, startSync, syncIsActive } from './sync' const token='b'.repeat(64) function response(value:unknown,status=200){return {ok:status>=200&&status<300,status,headers:new Headers({'X-Trainlog-CSRF-Token':token}),json:()=>Promise.resolve(value)}} describe('sync API',()=>{beforeEach(()=>vi.restoreAllMocks()) it('obtient passivement le statut et utilise le jeton pour la mutation',async()=>{const fetch=vi.fn().mockResolvedValueOnce(response({phase:'idle',result:'disabled'})).mockResolvedValueOnce(response({phase:'requested',result:'running'},202));vi.stubGlobal('fetch',fetch);await fetchSyncStatus();await startSync('sy_11111111-1111-4111-8111-111111111111');expect(fetch).toHaveBeenCalledTimes(2);const init=fetch.mock.calls[1][1];expect(init.method).toBe('POST');expect(init.headers['X-Trainlog-CSRF-Token']).toBe(token)}) it('rejette un statut inconnu et classe les phases actives',async()=>{vi.stubGlobal('fetch',vi.fn().mockResolvedValue(response({phase:'invented',result:'ok'})));await expect(fetchSyncStatus()).rejects.toThrow();expect(syncIsActive('waiting_acknowledgement')).toBe(true);expect(syncIsActive('completed')).toBe(false)}) + it('crée un UUIDv4 sans dépendre de randomUUID', () => { + vi.stubGlobal('crypto', { + getRandomValues: (bytes: Uint8Array) => { + bytes.set(Array.from({ length: 16 }, (_, index) => index)) + return bytes + }, + }) + expect(newRequestId()).toBe('sy_00010203-0405-4607-8809-0a0b0c0d0e0f') + }) }) diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts index 2c3f0a9..2e75d5f 100644 --- a/web/src/api/sync.ts +++ b/web/src/api/sync.ts @@ -6,5 +6,25 @@ const phases=new Set(['idle','requested','waiting_android_publication function parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus } export async function fetchSyncStatus(signal?:AbortSignal):Promise{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())} export async function startSync(requestId:string):Promise{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)} -export function newRequestId():string{return `sy_${crypto.randomUUID()}`} +export function newRequestId(): string { + /* + * WHY: trainlog.perf is loopback-routed HTTP but is not a browser secure + * context, so randomUUID() is legitimately unavailable there. + * CONTRACT: getRandomValues() supplies the UUID entropy; Trainlog sets the + * RFC 4122 version/variant bits and never falls back to Math.random(). + */ + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hexadecimal = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')) + const uuid = [ + hexadecimal.slice(0, 4).join(''), + hexadecimal.slice(4, 6).join(''), + hexadecimal.slice(6, 8).join(''), + hexadecimal.slice(8, 10).join(''), + hexadecimal.slice(10, 16).join(''), + ].join('-') + return `sy_${uuid}` +} export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}