fix(web): avoid crypto.randomUUID outside secure contexts

Closes #9306
This commit is contained in:
t8y2
2026-09-17 00:13:29 +08:00
committed by GitHub
parent 2c97e44d82
commit d35d578b03
14 changed files with 88 additions and 23 deletions
@@ -32,6 +32,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Database, Table, Columns, Loader2, Save, Upload, Settings, ChevronRight, X, AlertCircle, ArrowUp, ArrowDown } from "@lucide/vue";
import { ScrollArea } from "@/components/ui/scroll-area";
import { uuid } from "@/lib/common/utils";
const { t } = useI18n();
const store = useConnectionStore();
@@ -752,7 +753,7 @@ async function startInsert() {
execute: async () => {
executing.value = true;
insertCancelled.value = false;
const executionId = crypto.randomUUID();
const executionId = uuid();
activeExecutionId = executionId;
const startedAt = performance.now();
const perTable: TableResult[] = [];
@@ -7,6 +7,7 @@ import type { UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/sto
import type { UpdateDownloadProgress } from "@/lib/backend/tauri";
import { currentLocale } from "@/i18n";
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
import { uuid } from "@/lib/common/utils";
interface UseAppUpdaterOptions {
getActiveTaskCount?: () => number;
@@ -266,7 +267,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
automaticDownload = automatic;
const version = updateInfo.value!.latest_version;
const token = ++generation;
const attemptId = crypto.randomUUID();
const attemptId = uuid();
phase.value = "downloading";
downloadProgress.value = null;
clearError();
@@ -2,6 +2,7 @@ import { computed, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { filterModeNeedsValue, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
import { uuid } from "@/lib/common/utils";
export type DataGridStructuredFilterRule = {
id: string;
@@ -80,7 +81,7 @@ export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOption
const activeCount = computed(() => rules.value.filter((rule) => !rule.disabled && rule.columnName && options.isComplete(rule)).length);
function defaultRule(): DataGridStructuredFilterRule {
return { id: options.createId?.() ?? crypto.randomUUID(), columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" };
return { id: options.createId?.() ?? uuid(), columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" };
}
function ensureRule() {
if (!rules.value.length && toValue(options.columns).length) rules.value = [defaultRule()];
@@ -1,6 +1,7 @@
import { reactive, computed } from "vue";
import * as api from "@/lib/backend/api";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
import { uuid } from "@/lib/common/utils";
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer" | "multi-db-execution" | "schema-diff" | "data-compare";
export type BackgroundTaskStatus = "Running" | "Writing" | "Cancelling" | "Done" | "Error" | "Cancelled";
@@ -387,20 +388,8 @@ export function useExportTracker() {
const hasActive = computed(() => activeCount.value > 0);
function generateUUID() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
let buffer = new Uint8Array(16);
crypto.getRandomValues(buffer);
buffer[6] = (buffer[6] & 0x0f) | 0x40;
return Array.from(buffer, (b) => b.toString(16).padStart(2, "0"))
.join("")
.replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, "$1-$2-$3-$4-$5");
}
function addTask(tableName: string, format: string, filePath: string, exportId?: string): ExportTask {
const id = exportId ?? generateUUID();
const id = exportId ?? uuid();
const task = reactive<ExportTask>({
exportId: id,
kind: "table-export",
+2 -1
View File
@@ -15,6 +15,7 @@ import { qualifiedTableKey } from "./docsKeys";
import type { DocsRoute } from "./docsRoute";
import { groupBySchema, groupByTableGroup } from "./docsIndex";
import type { AnnotationFile, DocsEdit, GroupAnnotation, SchemaSnapshot } from "./types";
import { uuid } from "@/lib/common/utils";
const props = withDefaults(
defineProps<{
@@ -144,7 +145,7 @@ function home(): void {
*/
function createGroupFor(tableKey: string): void {
const group: GroupAnnotation = {
id: crypto.randomUUID(),
id: uuid(),
// Reuses the picker's own "New group" string rather than adding a locale
// key for a placeholder the user renames immediately in GroupEditor.
name: props.translate("docs.newGroup"),
@@ -1,5 +1,6 @@
import { onScopeDispose } from "vue";
import i18n from "@/i18n";
import { uuid } from "@/lib/common/utils";
const blockers = new Set<() => string | undefined>();
let barrierDepth = 0;
@@ -127,7 +128,7 @@ export async function setupUpdatePreparation(participant: UpdatePreparationParti
return {
async prepare() {
if (held.size) throw new Error(message("preparationBusy", "Update preparation already in progress"));
const id = crypto.randomUUID();
const id = uuid();
const members = (await getAllWebviewWindows()).map((item) => item.label).sort();
const expected = new Set(members.filter((item) => item !== label));
let timer: ReturnType<typeof setTimeout> | undefined;
+2 -1
View File
@@ -278,6 +278,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { collectBrowserSupportInfo } from "@/lib/app/supportInfo";
import { normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { AnnotationFile, SchemaSnapshot } from "@/docs/types";
import { uuid } from "@/lib/common/utils";
// ---------------------------------------------------------------------------
// Helpers
@@ -1412,7 +1413,7 @@ export async function executeMultiWithProgress(
executionId?: string;
},
): Promise<QueryResult[]> {
const executionId = options?.executionId ?? crypto.randomUUID();
const executionId = options?.executionId ?? uuid();
const { executionId: _executionId, ...executeOptions } = options ?? {};
const results = await executeMulti(connectionId, database, sql, schema, executionId, executeOptions);
const total = results.length;
+2 -1
View File
@@ -778,6 +778,7 @@ export async function saveMaxRetries(maxRetries: number): Promise<void> {
export type { OpenTabsStatePayload, PersistedEditorGroup } from "@/lib/app/openTabsPersistence";
import type { OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
import { uuid } from "@/lib/common/utils";
export async function loadEditorSettings(): Promise<unknown | null> {
return invoke("load_editor_settings");
@@ -1609,7 +1610,7 @@ export async function executeMultiWithProgress(
executionId?: string;
},
): Promise<QueryResult[]> {
const executionId = options?.executionId ?? crypto.randomUUID();
const executionId = options?.executionId ?? uuid();
const { executionId: _executionId, ...invokeOptions } = options ?? {};
const unlisten = await listen<ExecuteMultiProgress>("query-batch-progress", (event) => {
if (event.payload.executionId === executionId) onProgress(event.payload);
+10
View File
@@ -19,4 +19,14 @@ describe("uuid", () => {
expect(uuid()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
it("keeps working without crypto.randomUUID, the state of plain-HTTP deployments", () => {
// Issue #9306: the Docker build served over HTTP has crypto.getRandomValues but no
// crypto.randomUUID, and every bare crypto.randomUUID() call threw there.
const getRandomValues = vi.fn((buffer: Uint8Array) => buffer.fill(0xab));
vi.stubGlobal("crypto", { getRandomValues });
expect(uuid()).toBe("abababab-abab-4bab-abab-abababababab");
expect(getRandomValues).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,41 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* `crypto.randomUUID` exists only in secure contexts, so every bare call in the
* desktop/web bundle crashes under the plain-HTTP Docker deployment (issue #9306).
* The guard-compatible forms — `crypto?.randomUUID?.()`, `globalThis.crypto?.randomUUID?.()`
* — are allowed, as is the shared helper that owns the fallback.
*/
const SOURCE_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
const HELPER_PATH = join("lib", "common", "utils.ts");
const BARE_CALL = /(?<!\?)crypto\s*\.\s*randomUUID\s*\(/;
function sourceFiles(directory: string): string[] {
return readdirSync(directory).flatMap((entry) => {
const path = join(directory, entry);
if (statSync(path).isDirectory()) return entry === "__tests__" ? [] : sourceFiles(path);
return entry.endsWith(".ts") || entry.endsWith(".vue") ? [path] : [];
});
}
describe("crypto.randomUUID call sites", () => {
it("recognizes direct calls but not guard-compatible ones", () => {
expect(BARE_CALL.test("const id = crypto.randomUUID();")).toBe(true);
expect(BARE_CALL.test("globalThis.crypto.randomUUID()")).toBe(true);
expect(BARE_CALL.test("crypto?.randomUUID?.() || fallback")).toBe(false);
expect(BARE_CALL.test("globalThis.crypto?.randomUUID?.() ?? fallback")).toBe(false);
});
it("routes every UUID through the shared helper instead of calling crypto.randomUUID directly", () => {
const offenders = sourceFiles(SOURCE_ROOT)
.map((path) => ({ path: relative(SOURCE_ROOT, path).split(sep).join("/"), text: readFileSync(path, "utf8") }))
.filter(({ path }) => path !== HELPER_PATH.split(sep).join("/"))
.filter(({ text }) => BARE_CALL.test(text))
.map(({ path }) => path);
expect(offenders).toEqual([]);
});
});
+17
View File
@@ -6,10 +6,27 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Generates a random UUID (v4).
*
* `crypto.randomUUID` is exposed only in secure contexts, so the Docker/Web build
* served over plain HTTP on a LAN address sees it as `undefined` while
* `crypto.getRandomValues` keeps working (issue #9306). Call this helper instead of
* `crypto.randomUUID()` directly; the generated value only has to be unique, so the
* non-secure fallbacks are acceptable.
*/
export function uuid(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"))
.join("")
.replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, "$1-$2-$3-$4-$5");
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
+1 -1
View File
@@ -3527,7 +3527,7 @@ export const useQueryStore = defineStore("query", () => {
connectionId,
providerId,
connectionType: connection.plugin_connection_type,
workbenchId: crypto.randomUUID(),
workbenchId: uuid(),
connection: {
id: connection.id,
name: connection.name,
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
"apps/desktop/src/docs-export/exportPayload.ts": "d2355903843aa8a2e37b83827de9c4df2187d6df92fd514e3ab2fd89390e4b6c",
"apps/desktop/src/docs-export/exportTranslate.ts": "2e01bf9ad20fb38f3dfab96769ed87c44a24e5eb8d0aa352f4b398606fb77e3b",
"apps/desktop/src/docs-export/main.ts": "08c541f19fa6e368ea42fd096320da4312624f1961cd2b59387aa618288fc397",
"apps/desktop/src/docs/DocsApp.vue": "60fa8c3f434efb39622688f8ad65dcad77eedd99bde9ffb9103d711519aa29d3",
"apps/desktop/src/docs/DocsApp.vue": "5b7ad3b550a77432563a72e8452643bc044f0bbaad858b3f8a5909c46bf78503",
"apps/desktop/src/docs/components/ColumnTable.vue": "a025b78a96eb194d0768afc8ecb7653e5066dcb2af05bbbec925f7b7e5ced1e6",
"apps/desktop/src/docs/components/DocsSearch.vue": "163d585dc3400b30d7ff37b4efec7ce646933699131e11e4120979b2d3286c78",
"apps/desktop/src/docs/components/DocsSidebar.vue": "cf4a394b9b1ceff18e463c3ba5366921b61033d6ccf530373a9b300b23f0baf8",
@@ -37,6 +37,7 @@
"apps/desktop/src/i18n/locales/docs/zh-CN.ts": "2092c39277f2a6774405124d1f167bbcbc5f633d8f82bbd2b28ff5e12d78f3f7",
"apps/desktop/src/i18n/locales/docs/zh-TW.ts": "691cf502248dbd09358c6c276990945013b26c52fb682bcd56b26f378daea29c",
"apps/desktop/src/lib/app/localeOptions.ts": "2c19ecb7c91050d5f1c84c381606087ada11870d3ee95029362f84f34de23239",
"apps/desktop/src/lib/common/utils.ts": "76c4b2af1e03edeb8db389d9b2c1b56b72a57a1b5198d4e224671c1445c527da",
"apps/desktop/src/lib/diagram/erDiagram.ts": "e28aa2b5fbba001ae78bc0c4218b11f93b0e14ab9f54562f14254386b98bad11",
"apps/desktop/src/styles/tokens.css": "b8852ce50865ad98ebc992971f7a06670c5d830ca82efcee956f8d3eb7c2e9a3",
"apps/desktop/tsconfig.json": "5ad521941835ad8529e9b81ba79c11650e5f020f0e376b7a9f17e22a9306fa76",