fix the clipboard over http, and audit the rest
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+2
-1
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<boolean> {
|
||||
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';
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
+2
-1
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text);
|
||||
copyToClipboard(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { fullDate, timeAgo, timeUntil } from './format';
|
||||
import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards';
|
||||
import { EmptyBody, ViewShell } from './ViewShell';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// Device invites — the admin surface of COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5.
|
||||
//
|
||||
@@ -44,7 +45,7 @@ const TTL_OPTIONS = [
|
||||
const CopyButton = ({ value, label }: { value: string; label: string }) => {
|
||||
const [done, setDone] = useState(false);
|
||||
const copy = () => {
|
||||
void navigator.clipboard?.writeText(value);
|
||||
void copyToClipboard(value);
|
||||
setDone(true);
|
||||
window.setTimeout(() => setDone(false), 1500);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServer
|
||||
import { timeAgo, timeUntil, fullDate } from './format';
|
||||
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
|
||||
import { ViewShell, EmptyBody } from './ViewShell';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// Pre-auth keys — the tokens a machine presents to join the tailnet.
|
||||
//
|
||||
@@ -28,7 +29,7 @@ const STATUS_TONE = { active: 'ok', used: 'idle', expired: 'bad' } as const;
|
||||
const CopyButton = ({ value, label }: { value: string; label: string }) => {
|
||||
const [done, setDone] = useState(false);
|
||||
const copy = () => {
|
||||
void navigator.clipboard?.writeText(value);
|
||||
void copyToClipboard(value);
|
||||
setDone(true);
|
||||
window.setTimeout(() => setDone(false), 1500);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import { headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { timeAgo, timeUntil, fullDate } from './format';
|
||||
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
|
||||
import { ViewShell, EmptyBody } from './ViewShell';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// The nodes section — the machines in the tailnet.
|
||||
//
|
||||
@@ -28,7 +29,7 @@ import { ViewShell, EmptyBody } from './ViewShell';
|
||||
// rather than shown as the bare 0.0.0.0/0 that it advertises. Approving one route sends the whole approved
|
||||
// set upstream; the sidecar does that read-modify-write so two panels can't clobber each other's sets.
|
||||
|
||||
const copy = (text: string) => void navigator.clipboard?.writeText(text);
|
||||
const copy = (text: string) => void copyToClipboard(text);
|
||||
|
||||
type RouteRowProps = { route: string; approved: boolean; busy: boolean; onToggle: (approved: boolean) => void };
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { ALBUM_PARAM, formatShortDate, photosSectionPath, thumbUrl } from './shared';
|
||||
import { useAlbums, useSharedLinks } from './usePhotosData';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// Sharing: shared albums and shared links, read-mostly.
|
||||
//
|
||||
@@ -84,7 +85,7 @@ export const SharingSection = () => {
|
||||
size="icon"
|
||||
title="Copy share key — append it to your instance's /share/ URL"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(link.key);
|
||||
void copyToClipboard(link.key);
|
||||
toast.success('Share key copied');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { SearchAddon } from '@xterm/addon-search';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// Scrollback the browser keeps. The sidecar keeps its own replay buffer for re-attach; this is what you
|
||||
// can scroll back to within a live session, and 1000 (xterm's default) is about one long agent turn.
|
||||
@@ -188,7 +189,7 @@ export const TerminalView = ({
|
||||
if (!data || data === '?') return true;
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
|
||||
void navigator.clipboard?.writeText(new TextDecoder().decode(bytes));
|
||||
void copyToClipboard(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
// Malformed base64, no clipboard API, or permission refused.
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PRIORITY_LABELS,
|
||||
statusLabel,
|
||||
} from '../format';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// The "what is this torrent" tab: transfer numbers, then the immutable facts from the metainfo.
|
||||
|
||||
@@ -108,9 +109,10 @@ const CopyButton = ({ value }: { value: string }) => {
|
||||
type="button"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
// navigator.clipboard needs a secure context; Officer is always behind HTTPS, but a failure here
|
||||
// should be silent rather than an unhandled rejection in the console.
|
||||
void navigator.clipboard?.writeText(value).then(
|
||||
// copyToClipboard handles the insecure-origin case — navigator.clipboard does not exist over
|
||||
// plain http, which Officer IS reached over on a tailnet. This comment used to say "Officer is
|
||||
// always behind HTTPS"; it was not, and the button silently copied nothing.
|
||||
void copyToClipboard(value).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InvoiceState, PaymentStatus } from './shared';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// Display formatting for the Wallet panels.
|
||||
//
|
||||
@@ -144,7 +145,7 @@ export const PAYMENT_TONES: Record<PaymentStatus, string> = {
|
||||
/** Clipboard with a graceful failure — an insecure origin has no navigator.clipboard at all. */
|
||||
export async function copyToClipboard(value: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
await copyToClipboard(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user