mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -57,9 +57,14 @@ pub fn ptt_stop() {
|
||||
}
|
||||
|
||||
/// Set the PTT virtual key code. Pass 0 to disable.
|
||||
/// Valid range: 0 (disabled) or 1–254 (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 });
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (1–254)
|
||||
- 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user