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:
2026-08-13 03:56:57 +00:00
co-authored by Claude Opus 5
parent 77f1284925
commit cd209483e3
20 changed files with 213 additions and 22 deletions
@@ -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');
@@ -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);
@@ -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');
}}
>