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
20 changes: 20 additions & 0 deletions .changeset/clever-hounds-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/console': patch
---

Console: restore `crypto.randomUUID` on insecure origins so list views stop crashing on LAN IPs

`crypto.randomUUID` is exposed only in secure contexts (HTTPS or
`http://localhost`). Reaching a dev box over plain HTTP from another machine —
`http://192.168.x.x:4001/_console/`, the ordinary second-device flow — left the
method undefined, and every unguarded caller threw
`TypeError: crypto.randomUUID is not a function`, taking the console's list
views into the ErrorBoundary.

The console's HTML entry now installs an RFC 4122 v4 fallback built on
`crypto.getRandomValues` (which is not secure-context-gated, so the entropy
stays cryptographic). It runs as an inline classic script, synchronously during
parse, so it precedes every bundled chunk. It is guarded on absence and never
replaces a native implementation, so secure origins are unaffected; with no
entropy source available it installs nothing rather than degrading to
`Math.random`.
72 changes: 72 additions & 0 deletions apps/console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,78 @@
<script>
window.process = window.process || { env: { NODE_ENV: 'development' }, version: '', platform: 'browser' };
</script>
<!--
crypto.randomUUID fallback for INSECURE ORIGINS (objectui#4563).

`crypto.randomUUID` is secure-context-only, so on `http://LAN-IP` — the
ordinary way a second device reaches a dev box — the browser omits it and
every unguarded caller throws "crypto.randomUUID is not a function",
taking the console's list views into the ErrorBoundary. The console's own
graph carries unguarded calls in five packages, and the report's stack
attributes the throwing frame to a VENDORED chunk this repository does not
author, so guaranteeing the platform method is the only fix that reaches
every caller. `crypto.getRandomValues` is NOT secure-context-gated, so the
entropy stays cryptographic; we only rebuild the RFC 4122 formatting.

WHY THIS IS AN INLINE CLASSIC SCRIPT, and not a module one.
A classic inline script runs SYNCHRONOUSLY, during parse, before any
module chunk executes. That is the only bundler-independent guarantee, and
it is not theoretical: shipping this as a separate module-type entry
placed above the app entry was MEASURED to be too late. Vite merges the
two HTML module entries into a single chunk, and the merged entry's static
imports are hoisted above the shim's body — 16 chunks, vendor-react,
ui-components and RecordDetailView among them, evaluated before the shim
installed. Document order between module scripts is real but it is not
preserved through bundling, so it cannot carry this guarantee.

Behaviour, pinned by src/__tests__/insecure-origin-crypto.test.ts, which
extracts THIS script text and executes it (so the tests grade exactly what
ships, with no second copy to drift): guarded on ABSENCE, so a native
implementation is never replaced; and with no entropy source it installs
NOTHING rather than degrading to Math.random — an id generator that only
looks like crypto is worse than the honest absence. Surfacing that state
to the user is objectui#4570, deliberately not this shim's job.
-->
<script>
(function installInsecureOriginRandomUuid() {
var cryptoRef = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined;
if (!cryptoRef || typeof cryptoRef.getRandomValues !== 'function') return;
// Never override a working implementation (a real CSPRNG must win).
if (typeof cryptoRef.randomUUID === 'function') return;

var octets = [];
for (var i = 0; i < 256; i++) octets.push((i + 0x100).toString(16).slice(1));

var randomUUID = function () {
var b = new Uint8Array(16);
cryptoRef.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
return (
octets[b[0]] + octets[b[1]] + octets[b[2]] + octets[b[3]] + '-' +
octets[b[4]] + octets[b[5]] + '-' +
octets[b[6]] + octets[b[7]] + '-' +
octets[b[8]] + octets[b[9]] + '-' +
octets[b[10]] + octets[b[11]] + octets[b[12]] + octets[b[13]] +
octets[b[14]] + octets[b[15]]
);
};

try {
Object.defineProperty(cryptoRef, 'randomUUID', {
value: randomUUID,
writable: true,
configurable: true,
});
} catch (_defineFailed) {
try {
cryptoRef.randomUUID = randomUUID;
} catch (_assignFailed) {
/* Nothing installable here — refuse rather than pretend. */
}
}
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* objectui#4563 — the shim's PLACEMENT is the fix, not just its code.
*
* A `crypto.randomUUID` fallback that runs after a consumer has already called
* the missing method fixes nothing, so what this file defends is ORDER.
*
* The guarantee relied upon is that a CLASSIC inline script executes
* synchronously during parse, before any module script and therefore before any
* bundled chunk. The weaker guarantee — document order between two
* `type="module"` scripts — was tried first and MEASURED to fail: Vite merges
* multiple HTML module entries into one chunk, whose static imports are hoisted
* above the merged body, so 16 chunks (`vendor-react`, `ui-components` and
* `RecordDetailView` among them) evaluated before the shim installed. Document
* order is real in the browser but it does not survive bundling.
*
* Hence the assertions below: the shim must stay a classic inline script, and
* it must stay ahead of every script that carries a `src`.
*/
// Read through Vite's `?raw` rather than `node:fs`: this app's tsconfig is
// browser-only (`lib: ES2020, DOM`, and `types` without `node`), so a
// `node:fs`/`node:path` import fails `tsc` in the console's build even while
// the test itself passes under Vitest.
import html from '../../index.html?raw';
import { describe, it, expect } from 'vitest';

const SHIM_MARKER = 'installInsecureOriginRandomUuid';
const APP_ENTRY = '/src/main.tsx';

/**
* Every `<script ...>` open tag plus its inline body, in document order.
*
* HTML comments are stripped FIRST, and that is load-bearing rather than
* tidiness: prose describing a script tag reads as a script tag to any regex,
* and a comment above this very shim once did exactly that — the parse paired
* the comment's opening tag with the shim's closing tag and reported the shim
* as a `type="module" src=...` script. (Measured; this test failed that way.)
*/
const scripts = [
...html.replace(/<!--[\s\S]*?-->/g, '').matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/g),
].map((match) => ({
attrs: match[1] ?? '',
body: match[2] ?? '',
}));

const shimIndex = scripts.findIndex((script) => script.body.includes(SHIM_MARKER));
const entryIndex = scripts.findIndex((script) => script.attrs.includes(APP_ENTRY));

describe('apps/console/index.html — insecure-origin crypto shim placement', () => {
it('is present at all', () => {
expect(shimIndex).toBeGreaterThan(-1);
});

it('is a CLASSIC inline script, not a module', () => {
// The whole guarantee rests on this: `type="module"` would make it
// deferred, and `src=` would make it a bundled chunk. Either turns the
// synchronous parse-time install into an install that happens too late.
const shim = scripts[shimIndex];
expect(shim?.attrs).not.toContain('type="module"');
expect(shim?.attrs).not.toContain('src=');
});

it('runs BEFORE the application entry', () => {
expect(entryIndex).toBeGreaterThan(-1);
expect(shimIndex).toBeLessThan(entryIndex);
});

it('runs before EVERY script that loads external code', () => {
// Stronger than the pairwise check: no `src`-carrying script — the entry
// today, anything added later — may be scheduled ahead of the shim.
scripts.forEach((script, index) => {
if (!script.attrs.includes('src=')) return;
expect(
index,
`script #${index} (${script.attrs.trim()}) is scheduled before the #4563 shim`
).toBeGreaterThan(shimIndex);
});
});

it('is preceded only by inline classic scripts', () => {
// Everything before the shim (early branding, the `window.process`
// polyfill) must itself be inline and classic, so nothing can execute
// bundled code ahead of the install.
for (const script of scripts.slice(0, shimIndex)) {
expect(script.attrs).not.toContain('src=');
expect(script.attrs).not.toContain('type="module"');
}
});

it('keeps the application entry a module script', () => {
expect(scripts[entryIndex]?.attrs).toContain('type="module"');
});
});
Loading
Loading