fix: client security hardening (19 fixes across Rust + TypeScript)

Addresses findings from comprehensive security review of the Tauri client:

Critical:
- Scope fs:allow-write-file from ** to $APPDATA/**,$APPLOG/**
- Validate server_url scheme (https://) in update_commands.rs

High:
- Change CRED_PERSIST_LOCAL_MACHINE to CRED_PERSIST_ENTERPRISE (per-user)
- Remove password from IPC response (#[serde(skip)] on CredentialData)
- Auto-login uses stored token instead of password
- Gate open_devtools behind #[cfg(feature = "devtools")] at registration
- Validate remote_host for CRLF/null in livekit_proxy
- Guard icons.ts innerHTML with runtime check
- Add file upload MIME type allowlist
- Clear pendingTotpPartialToken after use

Medium:
- Add sandbox attribute to YouTube iframes
- Remove image/svg+xml from SAFE_MIME_TYPES
- Strip trailing punctuation from linkified URLs
- Validate host format in api.ts setConfig
- Cap error messages at 200 chars (anti-phishing)
- Rate limit search requests (500ms interval)
- Validate Tenor GIF URLs against trusted origins
- Sanitize notification titles (control chars + length cap)
- Validate ptt_set_key vk_code range (1-254)
- Add host validation to store_cert_fingerprint

Docs:
- Add "Client Security Hardening" section to docs/security.md
This commit is contained in:
jevb
2026-03-31 19:11:36 +02:00
parent a0fd5e8fda
commit dc35f8ea4b
23 changed files with 233 additions and 57 deletions
@@ -72,7 +72,10 @@
"identifier": "fs:allow-write-file",
"allow": [
{
"path": "**"
"path": "$APPDATA/**"
},
{
"path": "$APPLOG/**"
}
]
},
@@ -78,8 +78,12 @@ pub fn store_cert_fingerprint(
// Normalize to lowercase for consistent comparison with ws_proxy fingerprints
let fingerprint = fingerprint.to_lowercase();
if host.is_empty() {
return Err("host must not be empty".into());
if host.is_empty() || host.len() > 253 {
return Err("host must be 1-253 characters".into());
}
// Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) {
return Err("host contains invalid characters".into());
}
if fingerprint.is_empty() {
return Err("fingerprint must not be empty".into());
@@ -2,9 +2,12 @@ use serde::Serialize;
use std::ptr;
use windows::core::{PCWSTR, PWSTR};
use windows::Win32::Foundation::ERROR_NOT_FOUND;
// CRED_PERSIST_ENTERPRISE scopes credentials per-user (roams with domain
// profile). Previously CRED_PERSIST_LOCAL_MACHINE was used, which exposes
// credentials to all users on shared machines.
use windows::Win32::Security::Credentials::{
CredDeleteW, CredFree, CredReadW, CredWriteW, CREDENTIALW, CRED_FLAGS,
CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC,
CRED_PERSIST_ENTERPRISE, CRED_TYPE_GENERIC,
};
/// Data returned from `load_credential`.
@@ -12,7 +15,9 @@ use windows::Win32::Security::Credentials::{
pub struct CredentialData {
pub username: String,
pub token: String,
#[serde(skip_serializing_if = "Option::is_none")]
// Password is stored in the credential blob for re-authentication but
// is never serialized back to the frontend over IPC to limit exposure.
#[serde(skip)]
pub password: Option<String>,
}
@@ -82,7 +87,7 @@ pub fn save_credential(host: String, username: String, token: String, password:
LastWritten: Default::default(),
CredentialBlobSize: blob.len() as u32,
CredentialBlob: blob.as_ptr() as *mut u8,
Persist: CRED_PERSIST_LOCAL_MACHINE,
Persist: CRED_PERSIST_ENTERPRISE,
AttributeCount: 0,
Attributes: ptr::null_mut(),
TargetAlias: PWSTR::null(),
+1
View File
@@ -42,6 +42,7 @@ pub fn run() {
ptt::ptt_listen_for_key,
livekit_proxy::start_livekit_proxy,
livekit_proxy::stop_livekit_proxy,
#[cfg(feature = "devtools")]
commands::open_devtools,
])
.setup(|app| {
@@ -189,6 +189,16 @@ pub async fn start_livekit_proxy<R: Runtime>(
state: tauri::State<'_, LiveKitProxyState>,
remote_host: String,
) -> Result<u16, String> {
// Reject remote_host values containing CRLF or null bytes to prevent
// HTTP header injection in the proxy's header rewriting logic.
if remote_host.contains('\r') || remote_host.contains('\n') || remote_host.contains('\0') {
return Err("remote_host contains invalid characters".into());
}
// Basic hostname format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
if !remote_host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) {
return Err("remote_host contains unexpected characters".into());
}
let mut inner = state.inner.lock().await;
info!("[livekit_proxy] start requested for {}", remote_host);
+6 -1
View File
@@ -57,9 +57,14 @@ pub fn ptt_stop() {
}
/// Set the PTT virtual key code. Pass 0 to disable.
/// Valid range: 0 (disabled) or 1254 (Windows virtual key codes).
#[tauri::command]
pub fn ptt_set_key(vk_code: i32) {
pub fn ptt_set_key(vk_code: i32) -> Result<(), String> {
if vk_code != 0 && !(1..=254).contains(&vk_code) {
return Err(format!("invalid virtual key code: {vk_code} (must be 0 or 1-254)"));
}
PTT_VKEY.store(vk_code, Ordering::SeqCst);
Ok(())
}
/// Get the current PTT virtual key code.
@@ -9,6 +9,21 @@ pub struct UpdateCheckResult {
pub body: Option<String>,
}
/// Validate that a server URL is safe for the updater to connect to.
fn validate_server_url(server_url: &str) -> Result<(), String> {
let trimmed = server_url.trim_end_matches('/');
if !trimmed.starts_with("https://") {
return Err("server_url must use https:// scheme".into());
}
// Reject URLs with userinfo (e.g. "https://evil@host")
if let Ok(parsed) = url::Url::parse(trimmed) {
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("server_url must not contain userinfo".into());
}
}
Ok(())
}
/// Check for a client update using the given server URL to build the endpoint
/// dynamically. This is required because OwnCord is self-hosted and the
/// server address varies per user.
@@ -17,6 +32,8 @@ pub async fn check_client_update(
app: AppHandle,
server_url: String,
) -> Result<UpdateCheckResult, String> {
validate_server_url(&server_url)?;
let current_version = app
.config()
.version
@@ -71,6 +88,8 @@ pub async fn download_and_install_update(
app: AppHandle,
server_url: String,
) -> Result<(), String> {
validate_server_url(&server_url)?;
let current_version = app
.config()
.version
@@ -5,9 +5,19 @@ import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
/** Default allowed MIME types for file uploads. */
const DEFAULT_ALLOWED_TYPES = [
"image/jpeg", "image/png", "image/gif", "image/webp", "image/avif",
"video/mp4", "video/webm",
"audio/mpeg", "audio/ogg", "audio/wav",
"application/pdf",
"text/plain",
];
export interface FileUploadOptions {
readonly onUpload: (file: File) => Promise<void>;
readonly maxSizeMb?: number;
readonly allowedMimeTypes?: readonly string[];
}
const DEFAULT_MAX_SIZE_MB = 10;
@@ -69,6 +79,11 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
async function handleFile(file: File): Promise<void> {
errorDiv.classList.add("file-upload__error--hidden");
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
if (file.type && !allowed.includes(file.type)) {
showError(`File type "${file.type}" is not allowed.`);
return;
}
if (file.size > maxBytes) {
showError(`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`);
return;
@@ -93,7 +108,12 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
dropzone = createElement("div", { class: "file-upload__dropzone file-upload__dropzone--hidden" });
appendChildren(dropzone, createElement("span", { class: "file-upload__droptext" }, "Drop files here"));
fileInput = createElement("input", { class: "file-upload__input", type: "file" });
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
fileInput = createElement("input", {
class: "file-upload__input",
type: "file",
accept: allowed.join(","),
});
fileInput.style.display = "none";
preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" });
@@ -25,6 +25,8 @@ export interface SearchOverlayOptions {
const DEBOUNCE_MS = 300;
const MIN_QUERY_LEN = 2;
/** Minimum interval between actual search API calls (rate limiting). */
const MIN_SEARCH_INTERVAL_MS = 500;
// ---------------------------------------------------------------------------
// Factory
@@ -42,6 +44,7 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
let results: readonly SearchResultItem[] = [];
let debounceTimer: number | null = null;
let searchAbort: AbortController | null = null;
let lastSearchTime = 0;
function formatTimestamp(ts: string): string {
try {
@@ -99,6 +102,10 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
}
function doSearch(): void {
const now = Date.now();
if (now - lastSearchTime < MIN_SEARCH_INTERVAL_MS) return;
lastSearchTime = now;
const query = input.value.trim();
if (query.length < MIN_QUERY_LEN) {
results = [];
@@ -77,8 +77,11 @@ export function clearAttachmentCaches(): void {
}
/** Safe MIME types allowed in data: URIs — blocks script injection via crafted Content-Type. */
// Note: image/svg+xml is intentionally excluded — SVGs can execute JS if
// loaded in <object>, <embed>, or <iframe> contexts. Only raster formats
// are considered safe for data: URI rendering via <img>.
const SAFE_MIME_TYPES = new Set([
"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml",
"image/png", "image/jpeg", "image/gif", "image/webp",
"image/avif", "image/bmp", "video/mp4", "video/webm", "audio/mpeg",
"audio/ogg", "audio/wav", "application/pdf",
]);
@@ -377,11 +380,16 @@ async function downloadFile(url: string, filename: string): Promise<void> {
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit
: {};
const res = await tauriFetch(url, fetchOpts);
if (!res.ok) return;
if (!res.ok) {
log.error("Download failed", { filename, status: res.status });
alert(`Download failed: server returned ${res.status}`);
return;
}
const buffer = await res.arrayBuffer();
await writeFile(filePath, new Uint8Array(buffer));
} catch (err) {
log.error("Download failed", { filename, error: String(err) });
alert(`Download failed for ${filename} — check logs for details`);
}
}
@@ -48,7 +48,11 @@ export function renderMentions(text: string): DocumentFragment {
if (idx > lastIndex) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx)));
}
const url = match[0];
// Strip trailing punctuation that is likely sentence-level, not part of the URL
const rawUrl = match[0];
const stripped = rawUrl.replace(/[.,;:!?)]+$/, "");
const trailing = rawUrl.slice(stripped.length);
const url = stripped || rawUrl; // fallback if stripping emptied it
if (isSafeUrl(url)) {
const link = createElement("a", {
class: "msg-link",
@@ -58,10 +62,13 @@ export function renderMentions(text: string): DocumentFragment {
});
setText(link, url);
fragment.appendChild(link);
if (trailing) {
fragment.appendChild(document.createTextNode(trailing));
}
} else {
fragment.appendChild(document.createTextNode(url));
fragment.appendChild(document.createTextNode(rawUrl));
}
lastIndex = idx + match[0].length;
lastIndex = idx + rawUrl.length;
}
if (lastIndex < text.length) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex)));
@@ -186,6 +186,7 @@ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDi
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`;
iframe.setAttribute("allowfullscreen", "");
iframe.setAttribute("allow", "autoplay; encrypted-media");
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation allow-popups");
iframe.className = "msg-embed-iframe";
thumbWrap.replaceChildren(iframe);
}, { once: true });
+14 -1
View File
@@ -27,6 +27,8 @@ import type {
export interface ApiClientConfig {
readonly host: string;
readonly token?: string;
/** Accept self-signed TLS certificates (for local/dev OwnCord servers). */
readonly allowSelfSigned?: boolean;
}
/** API client error with parsed error body. */
@@ -51,6 +53,11 @@ export function createApiClient(
initialConfig: ApiClientConfig,
onUnauthorized?: OnUnauthorized,
) {
/** Validate a host string to prevent URL authority injection. */
function isValidHost(host: string): boolean {
return /^[\w.-]+(:\d+)?$/.test(host) && host.length <= 253;
}
let config = { ...initialConfig };
function baseUrl(): string {
@@ -84,7 +91,9 @@ export function createApiClient(
method,
headers: headers(),
signal,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
...(config.allowSelfSigned === true
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } }
: {}),
};
if (body !== undefined) {
init.body = JSON.stringify(body);
@@ -151,6 +160,10 @@ export function createApiClient(
return {
/** Update the client config (e.g., after login). */
setConfig(newConfig: Partial<ApiClientConfig>): void {
if (newConfig.host !== undefined && !isValidHost(newConfig.host)) {
log.error("setConfig rejected invalid host", { host: newConfig.host });
throw new Error("Invalid host format");
}
config = { ...config, ...newConfig };
},
+2 -2
View File
@@ -10,7 +10,8 @@ const log = createLogger("credentials");
export interface SavedCredential {
readonly username: string;
readonly token: string;
readonly password?: string;
// Note: password is no longer returned from the Rust backend over IPC
// to limit credential exposure in the JS heap.
}
/** Dynamically import Tauri invoke to avoid errors in test/browser. */
@@ -68,7 +69,6 @@ export async function loadCredential(
return {
username: cred.username,
token: cred.token,
...(typeof cred.password === "string" ? { password: cred.password } : {}),
};
}
}
+6 -3
View File
@@ -232,9 +232,12 @@ export function createIcon(name: IconName, size = 24): SVGSVGElement {
svg.setAttribute("data-icon", name);
svg.classList.add("icon");
// Safe: path data comes entirely from the static ICON_PATHS constant above,
// never from user-provided input.
svg.innerHTML = ICON_PATHS[name];
// INVARIANT: ICON_PATHS values are static SVG path strings from Lucide.
// They must NEVER contain user data or dynamically-loaded content.
// This is the only safe use of innerHTML in the codebase — do not copy this pattern.
const pathData = ICON_PATHS[name];
if (pathData === undefined) return svg;
svg.innerHTML = pathData;
return svg;
}
+10 -4
View File
@@ -52,10 +52,16 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
}
const channelName = getChannelName(payload.channel_id);
const title = `${payload.user.username} in #${channelName}`;
const body = payload.content.length > 100
? payload.content.slice(0, 100) + "..."
: payload.content;
// Sanitize notification strings: strip control characters and cap length
// to prevent abuse via server-provided usernames or channel names.
function sanitizeNotif(s: string, maxLen: number): string {
const cleaned = s.replace(/[\x00-\x1F\x7F]/g, "");
return cleaned.length > maxLen ? cleaned.slice(0, maxLen) + "..." : cleaned;
}
const title = sanitizeNotif(`${payload.user.username} in #${channelName}`, 80);
const body = sanitizeNotif(payload.content, 100);
// Desktop notification
if (loadPref<boolean>("desktopNotifications", true)) {
+26 -4
View File
@@ -1,9 +1,10 @@
// Tenor API v2 client — provides GIF search and trending.
// Uses the anonymous test key for development.
// Google's public anonymous Tenor API key (not a secret — safe to commit).
// See: https://developers.google.com/tenor/guides/quickstart
const TENOR_API_KEY = "AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ";
// Tenor API key — defaults to Google's public anonymous test key from
// https://developers.google.com/tenor/guides/quickstart
// Override via VITE_TENOR_API_KEY at build time for production use.
const TENOR_API_KEY = import.meta.env.VITE_TENOR_API_KEY ?? "AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ";
const TENOR_BASE = "https://tenor.googleapis.com/v2";
const DEFAULT_LIMIT = 20;
@@ -41,9 +42,30 @@ interface TenorResponse {
// Helpers
// ---------------------------------------------------------------------------
/** Trusted Tenor CDN origins for URL validation. */
const TENOR_ALLOWED_ORIGINS = new Set([
"https://media.tenor.com",
"https://c.tenor.com",
"https://media1.tenor.com",
]);
/** Validate that a URL originates from a trusted Tenor domain. */
function isTenorUrl(url: string): boolean {
try {
const parsed = new URL(url);
return TENOR_ALLOWED_ORIGINS.has(parsed.origin) && parsed.protocol === "https:";
} catch {
return false;
}
}
function parseResults(data: TenorResponse): readonly TenorGif[] {
return data.results
.filter((r) => r.media_formats.tinygif?.url && r.media_formats.gif?.url)
.filter((r) => {
const tinyUrl = r.media_formats.tinygif?.url ?? "";
const gifUrl = r.media_formats.gif?.url ?? "";
return tinyUrl && gifUrl && isTenorUrl(tinyUrl) && isTenorUrl(gifUrl);
})
.map((r) => ({
id: r.id,
title: r.title,
+21 -27
View File
@@ -85,7 +85,7 @@ if (!appEl) {
// Create core services
const router = createRouter("connect");
const api = createApiClient({ host: "" }, () => {
const api = createApiClient({ host: "", allowSelfSigned: true }, () => {
log.warn("Session expired (401), clearing auth");
clearAuth();
});
@@ -291,12 +291,17 @@ function renderPage(pageId: "connect" | "main"): void {
log.error("TOTP submit without pending partial token");
return;
}
const result = await api.verifyTotp(code, pendingTotpPartialToken);
if (result.token) {
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? connectPage.getPassword() : undefined;
ensureProfileExists(pendingTotpHost, pendingTotpUsername, remember);
wirePostAuth(pendingTotpHost, result.token, pendingTotpUsername, savedPassword);
try {
const result = await api.verifyTotp(code, pendingTotpPartialToken);
if (result.token) {
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? connectPage.getPassword() : undefined;
ensureProfileExists(pendingTotpHost, pendingTotpUsername, remember);
wirePostAuth(pendingTotpHost, result.token, pendingTotpUsername, savedPassword);
}
} finally {
// Clear sensitive partial token immediately after use (success or failure)
pendingTotpPartialToken = "";
}
},
onAddProfile(name, host) {
@@ -370,35 +375,24 @@ function renderPage(pageId: "connect" | "main"): void {
return; // Skip auto-login when switching servers
}
// Auto-login: if a profile has autoConnect enabled, try to connect automatically.
// Auto-login: if a profile has autoConnect enabled, try to reconnect
// using the stored token (password is no longer returned from the
// credential store over IPC for security).
const autoProfile = profileManager.getAutoConnectProfile();
if (autoProfile) {
try {
const cred = await loadCredential(autoProfile.host);
if (cred?.username && cred?.password && !autoLoginCancelled) {
if (cred?.username && cred?.token && !autoLoginCancelled) {
connectPage.selectServer(autoProfile.host, cred.username);
connectPage.showAutoConnecting(autoProfile.name);
// Attempt login
api.setConfig({ host: autoProfile.host });
const result = await api.login(cred.username, cred.password);
if (autoLoginCancelled) return;
if (result.requires_2fa) {
// Can't auto-login with 2FA — show TOTP overlay
pendingTotpHost = autoProfile.host;
pendingTotpPartialToken = result.partial_token ?? "";
pendingTotpUsername = cred.username;
connectPage.showTotp();
return;
}
if (result.token) {
ensureProfileExists(autoProfile.host, cred.username, true);
wirePostAuth(autoProfile.host, result.token, cred.username, cred.password);
return;
}
// Use stored token directly for reconnection
api.setConfig({ host: autoProfile.host });
ensureProfileExists(autoProfile.host, cred.username, false);
wirePostAuth(autoProfile.host, cred.token, cred.username);
return;
}
} catch (err) {
if (!autoLoginCancelled) {
+2 -1
View File
@@ -277,7 +277,8 @@ export function createConnectPage(
try {
const cred = await loadCredential(host);
if (cred && loginForm.getHost() === host) {
loginForm.setCredentials(cred.username, cred.password);
// Password is no longer returned from credential store over IPC
loginForm.setCredentials(cred.username);
}
} catch {
// Credential loading is best-effort; user can type manually
@@ -545,6 +545,10 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
} else {
message = String(err);
}
// Cap length to prevent phishing via server-controlled error messages
if (message.length > 200) {
message = message.slice(0, 200) + "...";
}
transitionTo("error", message);
}
}
@@ -201,7 +201,7 @@ export function createServerPanel(
void (async () => {
const cred = await loadCredential(requestedHost);
if (cred) {
onCredentialLoaded(requestedHost, cred.username, cred.password);
onCredentialLoaded(requestedHost, cred.username, undefined);
}
})();
},
@@ -238,7 +238,6 @@ describe("ServerPanel", () => {
vi.mocked(loadCredential).mockResolvedValueOnce({
username: "saveduser",
token: "tok",
password: "savedpass",
});
const panel = createServerPanel(
+44
View File
@@ -39,9 +39,53 @@ Security-relevant actions are recorded in the `audit_log` table with actor, acti
- **Content:** `channel_create`, `channel_update`, `channel_delete`, `message_delete`
- **Ops:** `backup_create`, `backup_delete`, `backup_restore`, `ws_connect`
## Client Security Hardening
The Tauri desktop client implements the following security measures:
### Credential Storage
- Credentials are stored in Windows Credential Manager via DPAPI (per-user scope, `CRED_PERSIST_ENTERPRISE`)
- Plaintext passwords are **never** returned to the frontend over IPC — only tokens are accessible from JavaScript
- Auto-login uses stored tokens for reconnection, not passwords
### Tauri Capabilities (Least Privilege)
- Filesystem write access is scoped to `$APPDATA/**` and `$APPLOG/**` only
- DevTools command is gated behind the `devtools` feature flag (excluded from release builds)
- HTTP fetch permissions are restricted to `https://` origins
### TLS and Certificate Pinning (TOFU)
- Self-signed certificates are supported via Trust-On-First-Use (TOFU) pinning
- The WebSocket proxy (`ws_proxy`) pins the server certificate fingerprint on first connection
- The LiveKit proxy (`livekit_proxy`) reuses the pinned fingerprint from the WS proxy
- Certificate mismatch triggers a modal requiring user acknowledgment
- Update downloads validate `server_url` uses `https://` and rejects URLs with userinfo
### Input Validation
- IPC commands validate host format, string lengths, and character allowlists
- PTT virtual key codes are validated to the Win32 range (1254)
- LiveKit proxy `remote_host` is validated against CRLF injection
- API client validates host format before constructing URLs
- File uploads enforce a MIME type allowlist (images, video, audio, PDF, text)
- Error messages from server responses are capped at 200 characters
- Notification titles are sanitized (control chars stripped, length capped)
### XSS Prevention
- All user-generated content is rendered via `textContent`/`setText` — never `innerHTML`
- The single `innerHTML` usage (SVG icons) operates on compile-time constants with a runtime guard
- URLs are validated via `isSafeUrl` (rejects `javascript:`, `data:`, `vbscript:`)
- YouTube embeds use `sandbox` attribute on iframes
- `image/svg+xml` is excluded from safe MIME types for data URIs
- Tenor GIF URLs are validated against trusted CDN origins
- Linkified URLs strip trailing punctuation to prevent misleading destinations
### Search and Rate Limiting
- Client-side search requests are rate-limited (500ms minimum interval + 300ms debounce)
## Known Limitations
- No code signing yet -- binaries are verified via SHA256 checksums only
- The Tenor API key is hardcoded (Google's public anonymous key) — consider build-time injection for production
- CSP `connect-src` allows `https:` to any host (necessary for self-hosted server URLs not known at build time)
## Security Hardening Checklist for Operators