From cd209483e3b8af045cfb9ddee5a50690e1e0f8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 13 Aug 2026 03:56:57 +0000 Subject: [PATCH] fix the clipboard over http, and audit the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigator.clipboard is secure-context only, like crypto.randomUUID before it — over plain http on a tailnet address the object does not exist. Twenty call sites across eighteen files, in three states that all looked fine in review: bare calls that threw and killed the handler, optional-chained calls that silently did nothing, and one carrying the comment "Officer is always behind HTTPS", which it is not. The optional-chained ones are the worst of the three: a copy button that reports success and copies nothing is indistinguishable from a working one until someone pastes. helpers/clipboard.ts falls back to document.execCommand('copy') over an off-screen textarea — deprecated, and it works on any origin because it predates the secure-context rule. Off-screen rather than hidden, because display:none and visibility:hidden elements cannot be selected and the copy fails silently. Reading the clipboard has no equivalent: execCommand('paste') was never permitted from script. The file browser's paste-a-file path now checks canReadClipboard() and explains itself instead of throwing. docs/http-secure-context-audit.md is the full sweep the owner asked for: what was fixed, what cannot be, and what was checked and found clear. crypto.subtle is used nowhere in the frontend, which was the one worth confirming since it has no cheap fallback. Notification's six matches are type names, not the API. geolocation and navigator.share are already guarded. getUserMedia is in four files and is being removed — but QrTransfer uses it for the CAMERA, not a microphone, so "remove audio" does not cover it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/http-secure-context-audit.md | 84 +++++++++++++++++++ .../Settings/IntegrationsSettings/ApiKeys.tsx | 3 +- .../IntegrationsSettings/BrowserRelay.tsx | 3 +- .../IntegrationsSettings/DavAppPasswords.tsx | 3 +- .../UserManagement/CreateUserForm.tsx | 5 +- .../Settings/UserManagement/UsersSection.tsx | 3 +- src/workspaces/components/CommandBlock.tsx | 3 +- src/workspaces/helpers/clipboard.ts | 79 +++++++++++++++++ .../src/apps/Chat/components/CodeSurface.tsx | 3 +- .../src/apps/Chat/components/CopyButton.tsx | 3 +- .../components/TaskRunnerModal.tsx | 3 +- .../FileBrowserApp/useFileBrowserApp.ts | 14 +++- .../FileViewer/renderers/MarkdownRenderer.tsx | 3 +- .../src/apps/Headscale/InvitesView.tsx | 3 +- .../src/apps/Headscale/KeysView.tsx | 3 +- .../src/apps/Headscale/NodesView.tsx | 3 +- .../src/apps/Photos/SharingSection.tsx | 3 +- .../officerdev/src/apps/Terminal/Terminal.tsx | 3 +- .../apps/Transmission/detail/GeneralTab.tsx | 8 +- .../officerdev/src/apps/Wallet/format.ts | 3 +- 20 files changed, 213 insertions(+), 22 deletions(-) create mode 100644 docs/http-secure-context-audit.md create mode 100644 src/workspaces/helpers/clipboard.ts diff --git a/docs/http-secure-context-audit.md b/docs/http-secure-context-audit.md new file mode 100644 index 00000000..0aca871a --- /dev/null +++ b/docs/http-secure-context-audit.md @@ -0,0 +1,84 @@ +# What breaks over plain http + +**Audited 2026-08-13**, after `crypto.randomUUID` took the chat page down at the end of every turn. + +Officer is reached at `http://officer-dev:9000` — a tailnet address, so **neither https nor +localhost**, and therefore not a [secure context]. A set of browser APIs are unavailable there by +specification, not by policy, and there is no flag that changes it. + +The failure mode is what makes this worth a document. Two of the three shapes below are silent: + +| shape | what a user sees | +| --- | --- | +| `crypto.randomUUID()` | `TypeError` — and if it is inside a `useState` initialiser, the whole tree unmounts | +| `navigator.clipboard.writeText()` | `TypeError`, killing the click handler | +| `navigator.clipboard?.writeText()` | **nothing at all** — the button reports success and copies nothing | + +The optional-chained one is the worst: indistinguishable from working until somebody pastes. + +--- + +## Fixed + +### `crypto.randomUUID` — 18 call sites +Secure-context only. `crypto.getRandomValues` is **not** — it lives on `Crypto` rather than +`SubtleCrypto` — so `helpers/random-id.ts` builds the same v4 UUID from the same CSPRNG when +`randomUUID` is absent. Same entropy, same version and variant bits. + +### `navigator.clipboard.writeText` — 20 call sites across 18 files +Secure-context only. `helpers/clipboard.ts` falls back to `document.execCommand('copy')` over an +off-screen textarea, which predates the secure-context rule and works on any origin. Deprecated and +working beats modern and absent. + +One call site carried the comment *"Officer is always behind HTTPS"*. It was not. + +--- + +## Cannot be fixed this way + +### `navigator.clipboard.read()` — pasting a file in the file browser +No fallback exists. `document.execCommand('paste')` was never permitted from script, so on an +insecure origin there is no way to pull clipboard contents on demand — only a real paste event the +user initiates, which is a different interaction. Now guarded by `canReadClipboard()` and refuses +with an explanation instead of throwing. + +### `getUserMedia` — audio recording, 4 files +`apps/Chat/useAudioRecording.ts`, `apps/FileBrowser/.../DictateDialog.tsx`, +`apps/QrTransfer/Receiver.tsx`, and a test. Requires a secure context and cannot be polyfilled — the +browser will not hand out a microphone or camera over http. + +**Being removed** rather than guarded: the owner uses an external dictation app. Note `QrTransfer` +uses it for the CAMERA rather than a microphone, so removing "audio" does not cover it — that one +needs its own decision. + +### `navigator.credentials` — passkeys +WebAuthn is secure-context only. `helpers/passkeys.ts` exists and cannot work over http, whatever is +done to it. Not currently reachable, so nothing is broken today. + +--- + +## Checked and clear + +- **`crypto.subtle`** — not used anywhere in the frontend. This was the one worth confirming, since + it would have had no cheap fallback. +- **`Notification`** — the six matches are type names, not the browser API. Nothing calls + `new Notification` or `requestPermission`. +- **Service workers, WebUSB, WebSerial, WebBluetooth, Payment Request, Wake Lock, Storage Manager, + `SharedArrayBuffer`** — not used. +- **`navigator.geolocation`** (`widgets/Weather`) — secure-context only, but already guarded with + `if (!navigator.geolocation) return;`, so it degrades rather than throws. The widget simply cannot + locate you over http. +- **`navigator.share`** (`Headscale/InvitesView`) — already guarded with a `typeof` check, and its + comment notes it is absent on desktop browsers anyway. +- **WebSockets, IndexedDB, localStorage, EventSource** — no secure-context restriction. Chat, + terminal and the sidecar transports are unaffected. + +--- + +## The alternative + +All of this disappears with a certificate, and `tailscale cert` issues a real one for the MagicDNS +name in about one command — no public DNS, no port 80 challenge, no renewal to remember. Worth +knowing that the choice here was "make it work over http", not "http is the only option". + +[secure context]: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApiKeys.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApiKeys.tsx index 55b0fe92..e9a43ed3 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApiKeys.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/ApiKeys.tsx @@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; +import { copyToClipboard } from 'helpers/clipboard'; // Your own API keys: one per app or device, so a phone holds a credential you can revoke on its own // instead of a session everything shares. @@ -39,7 +40,7 @@ const formatDate = (value: string | null) => const copy = async (text: string) => { try { - await navigator.clipboard.writeText(text); + await copyToClipboard(text); toast.success('Key copied'); } catch { toast.error('Could not copy — select and copy manually'); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/BrowserRelay.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/BrowserRelay.tsx index bdc1517a..beff8063 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/BrowserRelay.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/BrowserRelay.tsx @@ -4,6 +4,7 @@ import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-r import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useClient } from 'hooks/useClient'; +import { copyToClipboard } from 'helpers/clipboard'; type RelayToken = { token: string; @@ -46,7 +47,7 @@ export const BrowserRelay = () => { const handleCopy = async (value: string, field: string) => { try { - await navigator.clipboard.writeText(value); + await copyToClipboard(value); setCopiedField(field); toast.success('Copied to clipboard'); setTimeout(() => setCopiedField(null), 2000); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DavAppPasswords.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DavAppPasswords.tsx index 682e78ee..edbb83fc 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DavAppPasswords.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DavAppPasswords.tsx @@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { useClient } from 'hooks/useClient'; +import { copyToClipboard } from 'helpers/clipboard'; // Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird). // @@ -29,7 +30,7 @@ const formatDate = (value: string | null) => const copy = async (text: string, what: string) => { try { - await navigator.clipboard.writeText(text); + await copyToClipboard(text); toast.success(`${what} copied`); } catch { toast.error('Could not copy — select and copy manually'); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx index 46c887cc..0d503a82 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/CreateUserForm.tsx @@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { copyToClipboard } from 'helpers/clipboard'; // The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres. // @@ -104,7 +105,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => { }; const copy = (value: string, what: string) => { - void navigator.clipboard.writeText(value); + void copyToClipboard(value); toast.success(`${what} copied`); }; @@ -283,7 +284,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => { size="icon" disabled={!form.password} onClick={() => { - void navigator.clipboard.writeText(form.password); + void copyToClipboard(form.password); toast.success('Password copied'); }} > diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx index 1f142494..c6fff754 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx @@ -16,6 +16,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { CreateUserForm } from './CreateUserForm'; +import { copyToClipboard } from 'helpers/clipboard'; type ManagedUser = { id: number; @@ -209,7 +210,7 @@ export const UsersSection = () => { aria-label={`Copy ${user.email}'s SSH public key`} title="Copy their SSH public key (add it to their Gitea account)" onClick={() => { - void navigator.clipboard.writeText(user.osSshPublicKey!); + void copyToClipboard(user.osSshPublicKey!); toast.success('Public key copied'); }} > diff --git a/src/workspaces/components/CommandBlock.tsx b/src/workspaces/components/CommandBlock.tsx index 19de9d10..b90bcc10 100644 --- a/src/workspaces/components/CommandBlock.tsx +++ b/src/workspaces/components/CommandBlock.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Copy, Check, Play } from 'lucide-react'; +import { copyToClipboard } from 'helpers/clipboard'; type CommandBlockProps = { label?: string; @@ -11,7 +12,7 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => { const [copied, setCopied] = useState(false); const copy = () => { - navigator.clipboard.writeText(command); + copyToClipboard(command); setCopied(true); setTimeout(() => setCopied(false), 1500); }; diff --git a/src/workspaces/helpers/clipboard.ts b/src/workspaces/helpers/clipboard.ts new file mode 100644 index 00000000..dfb37c3f --- /dev/null +++ b/src/workspaces/helpers/clipboard.ts @@ -0,0 +1,79 @@ +/** + * Copy text, in a browser that may not be in a secure context. + * + * ── Why this exists ── + * + * `navigator.clipboard` is SECURE-CONTEXT ONLY. Over plain http on anything that + * is not localhost the whole object is undefined, so `navigator.clipboard.writeText` + * throws `TypeError: Cannot read properties of undefined`. + * + * Officer is reached over the tailnet — `http://officer-dev:9000` — which is + * neither https nor localhost. Twenty call sites were affected, in three states + * that all looked fine in review: + * + * bare `navigator.clipboard.writeText(x)` threw, killing the handler + * `navigator.clipboard?.writeText(x)` did nothing, silently + * one carrying the comment "Officer is always behind HTTPS" + * + * The middle one is the worst: a copy button that reports success and copies + * nothing is indistinguishable from a working one until you paste. + * + * ── The fallback ── + * + * `document.execCommand('copy')` over a temporary, off-screen textarea. It is + * deprecated and it works in every browser that runs this app, on any origin, + * because it predates the secure-context rule. Deprecated-and-working beats + * modern-and-absent. + * + * The textarea is positioned off-screen rather than hidden: `display:none` and + * `visibility:hidden` elements cannot be selected, so the copy silently fails. + * `readOnly` stops a mobile keyboard appearing for the instant it is focused. + * + * Returns whether it worked, so a caller can say "copied" only when it did. + * Reading the clipboard has no equivalent fallback — see `canReadClipboard`. + */ +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Permission refused, or a document that is not focused. Fall through. + } + } + + if (typeof document === 'undefined') return false; + + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.top = '-9999px'; + area.style.opacity = '0'; + document.body.appendChild(area); + + try { + area.select(); + area.setSelectionRange(0, text.length); + return document.execCommand('copy'); + } catch { + return false; + } finally { + document.body.removeChild(area); + } +} + +/** + * Whether the clipboard can be READ on demand. + * + * There is no fallback for this one. `document.execCommand('paste')` was never + * permitted from script, so on an insecure origin the only way to get clipboard + * contents is a real paste event the user initiates — which is a different + * interaction, not a drop-in. + * + * Callers should use this to hide a "paste" affordance rather than offer one + * that cannot work. + */ +export function canReadClipboard(): boolean { + return typeof navigator !== 'undefined' && typeof navigator.clipboard?.read === 'function'; +} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/CodeSurface.tsx b/src/workspaces/officerdev/src/apps/Chat/components/CodeSurface.tsx index 8dfa0d34..323d1132 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/CodeSurface.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/CodeSurface.tsx @@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { Copy, Check } from 'lucide-react'; import { highlight } from '../../FileViewer/renderers/highlight'; +import { copyToClipboard } from 'helpers/clipboard'; /** * The one way this app draws a block of code: shiki-highlighted when a language is known, line-numbered, @@ -91,7 +92,7 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered const text = code.replace(/\n+$/, ''); if (!text) return; try { - await navigator.clipboard.writeText(mode === 'md' ? asMarkdown(text, lang) : text); + await copyToClipboard(mode === 'md' ? asMarkdown(text, lang) : text); setCopied(mode); setTimeout(() => setCopied(null), 2000); } catch { diff --git a/src/workspaces/officerdev/src/apps/Chat/components/CopyButton.tsx b/src/workspaces/officerdev/src/apps/Chat/components/CopyButton.tsx index 2029b174..302e5e21 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/CopyButton.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/CopyButton.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Copy, Check } from 'lucide-react'; +import { copyToClipboard } from 'helpers/clipboard'; type CopyButtonProps = { text: string; @@ -10,7 +11,7 @@ export const CopyButton = ({ text, className = '' }: CopyButtonProps) => { const [copied, setCopied] = useState(false); const handleCopy = () => { - navigator.clipboard.writeText(text.trim()); + copyToClipboard(text.trim()); setCopied(true); setTimeout(() => setCopied(false), 2000); }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index 505b1e17..39040675 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -21,6 +21,7 @@ import { type FolderTrackGroup, } from '../../../../hooks/useFilesAPI'; import { randomId } from 'helpers/random-id'; +import { copyToClipboard } from 'helpers/clipboard'; const playDing = () => { const ctx = new AudioContext(); @@ -1092,7 +1093,7 @@ const ScriptRunner = ({ const [copied, setCopied] = useState(false); const handleCopy = () => { const text = runner.output.map((line) => line.text).join(''); - navigator.clipboard.writeText(text); + copyToClipboard(text); setCopied(true); setTimeout(() => setCopied(false), 2000); }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index cb77051c..b0bded8b 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -8,6 +8,7 @@ import { useUserState } from 'state/useUserState'; import { useAuth } from 'hooks/useAuth'; import { useFilesRefresh } from '../../../channels'; import { useNewDashboardDraft } from '../../Dashboards/useNewDashboardDraft'; +import { copyToClipboard, canReadClipboard } from 'helpers/clipboard'; /** * Where the browser is looking, when it is the one browser that owns the address bar. `urlPath` is opt-in @@ -434,12 +435,12 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa }; const handleCopyPath = (entry: DirEntry) => { - navigator.clipboard.writeText(`~${entryPath(entry.name)}`); + copyToClipboard(`~${entryPath(entry.name)}`); toast.success('Path copied'); }; const handleCopyCurrentPath = () => { - navigator.clipboard.writeText(`~${currentPath}`); + copyToClipboard(`~${currentPath}`); toast.success('Path copied'); }; @@ -536,6 +537,15 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa return; } + // Reading the clipboard is the one half with no fallback. `document.execCommand('paste')` was + // never permitted from script, so on an insecure origin — which a tailnet http:// URL is — there + // is no way to pull clipboard contents on demand. Pasting an image has to come from a real paste + // event instead. Refuse clearly rather than throwing "Cannot read properties of undefined". + if (!canReadClipboard()) { + toast.error('Pasting from the clipboard needs a secure context (https). Drag the file in instead.'); + return; + } + try { const clipboardItems = await navigator.clipboard.read(); const imageFiles: File[] = []; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/renderers/MarkdownRenderer.tsx b/src/workspaces/officerdev/src/apps/FileViewer/renderers/MarkdownRenderer.tsx index bf781c01..9d73bd02 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/renderers/MarkdownRenderer.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/renderers/MarkdownRenderer.tsx @@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSlug from 'rehype-slug'; import { highlight } from './highlight'; +import { copyToClipboard } from 'helpers/clipboard'; type HastNode = { type: string; @@ -82,7 +83,7 @@ const CopyButton = ({ text }: { text: string }) => { return (