Merge fix/client-id-insecure-context for client-side ids

This commit is contained in:
Grovy311 2026-09-26 14:22:35 +02:00
commit 80dbc73230
4 changed files with 84 additions and 4 deletions

View file

@ -177,6 +177,13 @@ The circuit list editor must remain a custom spreadsheet-like component with con
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing. Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
## Browser Runtime
The web UI is also opened over plain HTTP from other machines in the LAN, which
is not a secure context. Do not call secure-context-only browser APIs such as
`crypto.randomUUID()` directly; create client-side ids with `createClientId()`
from `src/frontend/utils/client-id.ts`.
## Drag-and-Drop Rules ## Drag-and-Drop Rules
Dragging from the circuit identifier / circuit handle moves the whole circuit. Dragging from the circuit identifier / circuit handle moves the whole circuit.

View file

@ -76,6 +76,7 @@ import {
type MutableDistributionBoardComponentRole, type MutableDistributionBoardComponentRole,
} from "../utils/distribution-board-component-editing"; } from "../utils/distribution-board-component-editing";
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history"; import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
import { createClientId } from "../utils/client-id";
import type { import type {
CellKey, CellKey,
ColumnDef, ColumnDef,
@ -977,7 +978,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
defaultSortOrder: number defaultSortOrder: number
) { ) {
return buildCircuitDeviceRowInsertSnapshot({ return buildCircuitDeviceRowInsertSnapshot({
id: crypto.randomUUID(), id: createClientId(),
circuitId, circuitId,
values, values,
defaultSortOrder, defaultSortOrder,
@ -1001,7 +1002,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
), ),
data data
); );
const circuitId = crypto.randomUUID(); const circuitId = createClientId();
const deviceRows = deviceRowValues.map((row, index) => const deviceRows = deviceRowValues.map((row, index) =>
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10) createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
); );
@ -1169,7 +1170,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
(section) => section.id === intent.sectionId (section) => section.id === intent.sectionId
)?.components ?? []; )?.components ?? [];
const snapshot = buildDistributionBoardComponentSnapshot({ const snapshot = buildDistributionBoardComponentSnapshot({
id: crypto.randomUUID(), id: createClientId(),
circuitListId, circuitListId,
role: intent.role, role: intent.role,
sectionId: intent.sectionId, sectionId: intent.sectionId,
@ -1234,7 +1235,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
projectId, projectId,
getExpectedProjectRevision(), getExpectedProjectRevision(),
buildNewCircuitGroupSnapshot({ buildNewCircuitGroupSnapshot({
id: crypto.randomUUID(), id: createClientId(),
circuitListId, circuitListId,
category: values.category, category: values.category,
displayName: values.displayName, displayName: values.displayName,

View file

@ -0,0 +1,27 @@
/**
* Creates a random RFC 4122 version 4 UUID for new entities built in the
* browser.
*
* crypto.randomUUID() exists only in secure contexts (HTTPS or localhost).
* The app is also opened over plain HTTP from other machines in the LAN, where
* it is undefined, so fall back to crypto.getRandomValues(), which is
* available in every context.
*/
export function createClientId(
source: Pick<Crypto, "getRandomValues"> & Partial<Pick<Crypto, "randomUUID">> = crypto
): string {
if (typeof source.randomUUID === "function") {
return source.randomUUID();
}
const bytes = source.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
const hex = Array.from(bytes, (byte) => byte.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),
].join("-");
}

45
tests/client-id.test.ts Normal file
View file

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { webcrypto } from "node:crypto";
import { describe, it } from "node:test";
import { z } from "zod";
import { createClientId } from "../src/frontend/utils/client-id.js";
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe("client id", () => {
it("uses crypto.randomUUID when the context provides it", () => {
const id = createClientId({
getRandomValues: () => {
throw new Error("fallback must not be used");
},
randomUUID: () => "11111111-2222-4333-8444-555555555555",
});
assert.equal(id, "11111111-2222-4333-8444-555555555555");
});
it("falls back to getRandomValues outside a secure context", () => {
const insecure = {
getRandomValues: webcrypto.getRandomValues.bind(webcrypto),
} as Pick<Crypto, "getRandomValues">;
const ids = new Set<string>();
for (let index = 0; index < 200; index += 1) {
const id = createClientId(insecure);
assert.match(id, uuidV4);
assert.equal(z.string().uuid().safeParse(id).success, true);
ids.add(id);
}
assert.equal(ids.size, 200);
});
it("sets version and variant bits regardless of the random bytes", () => {
const allOnes = {
getRandomValues: <T extends ArrayBufferView | null>(array: T) => {
if (array instanceof Uint8Array) {
array.fill(0xff);
}
return array;
},
} as Pick<Crypto, "getRandomValues">;
assert.equal(createClientId(allOnes), "ffffffff-ffff-4fff-bfff-ffffffffffff");
});
});