diff --git a/Cargo.lock b/Cargo.lock index b1a0a1b70..7d0698ea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4520,7 +4520,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.53.22" +version = "0.53.25" dependencies = [ "aes-gcm", "anyhow", diff --git a/app/src/services/__tests__/apiClient.test.ts b/app/src/services/__tests__/apiClient.test.ts new file mode 100644 index 000000000..f60f03df3 --- /dev/null +++ b/app/src/services/__tests__/apiClient.test.ts @@ -0,0 +1,64 @@ +import { getVersion } from '@tauri-apps/api/app'; +import { isTauri } from '@tauri-apps/api/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/api/app', () => ({ getVersion: vi.fn() })); + +describe('apiClient version headers', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.mocked(isTauri).mockReturnValue(false); + vi.stubGlobal('fetch', vi.fn()); + }); + + it('adds x-web-version on non-Tauri backend requests', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ success: true }), + } as Response); + + const { apiClient } = await import('../apiClient'); + await apiClient.get('/version-check', { requireAuth: false }); + + const requestInit = fetchMock.mock.calls[0][1] as RequestInit; + const headers = requestInit.headers as Record; + expect(headers['x-web-version']).toBe('0.0.0-test'); + expect(headers).not.toHaveProperty('x-tauri-version'); + }); + + it('adds sanitized x-tauri-version on Tauri backend requests', async () => { + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(getVersion).mockResolvedValue(' 1.2.3 (desktop)+build!? '); + + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ success: true }), + } as Response); + + const { apiClient } = await import('../apiClient'); + await apiClient.post('/version-check', { ok: true }, { requireAuth: false }); + + const requestInit = fetchMock.mock.calls[0][1] as RequestInit; + const headers = requestInit.headers as Record; + expect(headers['x-tauri-version']).toBe('1.2.3desktop+build'); + expect(headers).not.toHaveProperty('x-web-version'); + }); + + it('retries tauri version lookup after a transient failure', async () => { + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(getVersion) + .mockRejectedValueOnce(new Error('transient failure')) + .mockResolvedValueOnce('2.3.4'); + + const { getClientVersionHeaders } = await import('../clientVersionHeaders'); + + await expect(getClientVersionHeaders()).resolves.toEqual({}); + await expect(getClientVersionHeaders()).resolves.toEqual({ 'x-tauri-version': '2.3.4' }); + expect(getVersion).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/src/services/api/__tests__/authApi.test.ts b/app/src/services/api/__tests__/authApi.test.ts index 1aa23903d..04743aab0 100644 --- a/app/src/services/api/__tests__/authApi.test.ts +++ b/app/src/services/api/__tests__/authApi.test.ts @@ -17,7 +17,7 @@ describe('sendEmailMagicLink', () => { expect(fetchSpy).toHaveBeenCalledWith('http://localhost:5005/auth/email/send-link', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-web-version': '0.0.0-test' }, body: JSON.stringify({ email: 'user@example.com', frontendRedirectUri: 'openhuman://' }), signal: expect.any(AbortSignal), }); diff --git a/app/src/services/api/authApi.ts b/app/src/services/api/authApi.ts index cb3e6c567..291d92475 100644 --- a/app/src/services/api/authApi.ts +++ b/app/src/services/api/authApi.ts @@ -1,4 +1,5 @@ import { getBackendUrl } from '../backendUrl'; +import { getClientVersionHeaders } from '../clientVersionHeaders'; import { callCoreRpc } from '../coreRpcClient'; const EMAIL_MAGIC_LINK_TIMEOUT_MS = 15_000; @@ -20,9 +21,10 @@ export async function sendEmailMagicLink( const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { + const versionHeaders = await getClientVersionHeaders(); const response = await fetch(`${backendUrl}/auth/email/send-link`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...versionHeaders }, body: JSON.stringify({ email, frontendRedirectUri }), signal: controller.signal, }); diff --git a/app/src/services/apiClient.ts b/app/src/services/apiClient.ts index 8070e3eff..608d59f38 100644 --- a/app/src/services/apiClient.ts +++ b/app/src/services/apiClient.ts @@ -1,5 +1,6 @@ import type { ApiError } from '../types/api'; import { getBackendUrl } from './backendUrl'; +import { getClientVersionHeaders } from './clientVersionHeaders'; type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -36,10 +37,12 @@ class ApiClient { /** * Build headers for the request */ - private buildHeaders(options: RequestOptions): HeadersInit { + private async buildHeaders(options: RequestOptions): Promise { + const versionHeaders = await getClientVersionHeaders(); const headers: Record = { 'Content-Type': 'application/json', ...options.headers, + ...versionHeaders, }; // Add authorization header if auth is required @@ -61,7 +64,7 @@ class ApiClient { const baseUrl = await getBackendUrl(); const url = `${baseUrl}${endpoint}`; - const headers = this.buildHeaders({ ...options, requireAuth }); + const headers = await this.buildHeaders({ ...options, requireAuth }); console.log('request', { url, headers, body, method }); diff --git a/app/src/services/clientVersionHeaders.ts b/app/src/services/clientVersionHeaders.ts new file mode 100644 index 000000000..60c12e849 --- /dev/null +++ b/app/src/services/clientVersionHeaders.ts @@ -0,0 +1,44 @@ +import { getVersion } from '@tauri-apps/api/app'; + +import { APP_VERSION } from '../utils/config'; +import { isTauri } from '../utils/tauriCommands/common'; + +const CLIENT_VERSION_MAX_LENGTH = 64; + +let tauriVersionPromise: Promise | null = null; + +export function sanitizeClientVersion(raw: string | null | undefined): string | null { + const sanitized = String(raw ?? '') + .trim() + .replace(/[^0-9A-Za-z._+-]+/g, '') + .slice(0, CLIENT_VERSION_MAX_LENGTH); + + return sanitized.length > 0 ? sanitized : null; +} + +async function getTauriClientVersion(): Promise { + if (!isTauri()) { + return null; + } + + if (!tauriVersionPromise) { + tauriVersionPromise = getVersion() + .then(version => sanitizeClientVersion(version)) + .catch(() => { + tauriVersionPromise = null; + return null; + }); + } + + return tauriVersionPromise; +} + +export async function getClientVersionHeaders(): Promise> { + if (isTauri()) { + const tauriVersion = await getTauriClientVersion(); + return tauriVersion ? { 'x-tauri-version': tauriVersion } : {}; + } + + const webVersion = sanitizeClientVersion(APP_VERSION); + return webVersion ? { 'x-web-version': webVersion } : {}; +} diff --git a/app/src/test/mockApiCore.headersRedaction.test.ts b/app/src/test/mockApiCore.headersRedaction.test.ts new file mode 100644 index 000000000..d16ed9cf8 --- /dev/null +++ b/app/src/test/mockApiCore.headersRedaction.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'vitest'; + +// @ts-ignore - test-only JS module outside app/src +import { clearRequestLog, getMockServerPort } from '../../../scripts/mock-api-core.mjs'; + +it('redacts sensitive request headers in the mock API log', async () => { + clearRequestLog(); + + const mockApiUrl = `http://127.0.0.1:${getMockServerPort()}`; + await fetch(`${mockApiUrl}/__admin/health`, { + headers: { + Authorization: 'Bearer secret-token', + 'Proxy-Authorization': 'Basic secret-token', + 'X-Test-Version': '1.2.3', + }, + }); + + const requestsResponse = await fetch(`${mockApiUrl}/__admin/requests`); + const requestsPayload = (await requestsResponse.json()) as { + data?: Array<{ headers?: Record }>; + }; + const request = requestsPayload.data?.find( + entry => entry.headers?.['x-test-version'] === '1.2.3' + ); + expect(request?.headers).toMatchObject({ + authorization: '[REDACTED]', + 'proxy-authorization': '[REDACTED]', + 'x-test-version': '1.2.3', + }); +}); diff --git a/scripts/mock-api-core.mjs b/scripts/mock-api-core.mjs index aee4c51fb..d54532333 100644 --- a/scripts/mock-api-core.mjs +++ b/scripts/mock-api-core.mjs @@ -15,7 +15,7 @@ const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": - "Content-Type, Authorization, x-device-fingerprint", + "Content-Type, Authorization, x-device-fingerprint, x-tauri-version, x-core-version, x-ios-version, x-android-version, x-web-version", "Access-Control-Max-Age": "86400", }; @@ -154,6 +154,29 @@ function tryParseJson(raw) { } } +const REDACTED_HEADER_VALUE = "[REDACTED]"; +const SENSITIVE_HEADER_NAMES = new Set([ + "authorization", + "cookie", + "set-cookie", + "proxy-authorization", +]); + +function normalizeHeaders(headers) { + const entries = Object.entries(headers || {}); + return Object.fromEntries( + entries.map(([key, value]) => { + if (SENSITIVE_HEADER_NAMES.has(String(key).toLowerCase())) { + return [key, REDACTED_HEADER_VALUE]; + } + return [ + key, + Array.isArray(value) ? value.join(", ") : String(value ?? ""), + ]; + }), + ); +} + function getDelayMs(key) { const value = Number(mockBehavior[key] || 0); return Number.isFinite(value) && value > 0 ? value : 0; @@ -183,7 +206,13 @@ async function handleRequest(req, res) { const parsedBody = tryParseJson(body); const origin = requestOrigin(req); - requestLog.push({ method, url, body, timestamp: Date.now() }); + requestLog.push({ + method, + url, + body, + headers: normalizeHeaders(req.headers), + timestamp: Date.now(), + }); if (method === "OPTIONS") { setCors(res); @@ -270,7 +299,8 @@ async function handleRequest(req, res) { if ( method === "GET" && - (/^\/telegram\/me\/?(\?.*)?$/.test(url) || /^\/auth\/me\/?(\?.*)?$/.test(url)) + (/^\/telegram\/me\/?(\?.*)?$/.test(url) || + /^\/auth\/me\/?(\?.*)?$/.test(url)) ) { const delayMs = getDelayMs("telegramMeDelayMs"); if (delayMs > 0) { @@ -316,7 +346,9 @@ async function handleRequest(req, res) { fiveHourCapUsd: 5, fiveHourResetsAt: null, cycleStartDate: new Date().toISOString(), - cycleEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + cycleEndsAt: new Date( + Date.now() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(), bypassCycleLimit: false, }, }); @@ -796,8 +828,7 @@ async function handleRequest(req, res) { { id: "STREAK_7", title: "7-Day Streak", - description: - "Use OpenHuman on seven consecutive active days.", + description: "Use OpenHuman on seven consecutive active days.", actionLabel: "Keep your streak alive for 7 days", unlocked: false, progressLabel: "0 / 7 days", @@ -1114,10 +1145,9 @@ async function handleRequest(req, res) { method === "GET" && /^\/agent-integrations\/composio\/connections\/?(\?.*)?$/.test(url) ) { - const connections = parseBehaviorJson( - "composioConnections", - [{ id: "c1", toolkit: "gmail", status: "ACTIVE" }], - ); + const connections = parseBehaviorJson("composioConnections", [ + { id: "c1", toolkit: "gmail", status: "ACTIVE" }, + ]); json(res, 200, { success: true, data: { connections } }); return; } @@ -1150,10 +1180,12 @@ async function handleRequest(req, res) { json(res, 500, { success: false, error: "Mock enable trigger failure" }); return; } - const slug = typeof parsedBody?.slug === "string" ? parsedBody.slug.trim() : ""; - const connectionId = typeof parsedBody?.connectionId === "string" - ? parsedBody.connectionId.trim() - : ""; + const slug = + typeof parsedBody?.slug === "string" ? parsedBody.slug.trim() : ""; + const connectionId = + typeof parsedBody?.connectionId === "string" + ? parsedBody.connectionId.trim() + : ""; if (!slug) { json(res, 400, { success: false, error: "Missing required field: slug" }); return; @@ -1368,7 +1400,9 @@ function createServerInstance() { openSockets.add(socket); socket.on("close", () => openSockets.delete(socket)); }); - nextServer.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket)); + nextServer.on("upgrade", (req, socket) => + handleWebSocketUpgrade(req, socket), + ); return nextServer; } @@ -1396,7 +1430,8 @@ async function startMockServer(port = DEFAULT_PORT, options = {}) { return { port: getMockServerPort() ?? port, alreadyRunning: true }; } - const preferredPort = Number.isInteger(port) && port > 0 ? port : DEFAULT_PORT; + const preferredPort = + Number.isInteger(port) && port > 0 ? port : DEFAULT_PORT; const retryIfInUse = options.retryIfInUse === true; const candidatePorts = retryIfInUse ? [ diff --git a/src/api/rest.rs b/src/api/rest.rs index 064ddb98a..dc30af87f 100644 --- a/src/api/rest.rs +++ b/src/api/rest.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use base64::Engine; -use reqwest::header::AUTHORIZATION; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION}; use reqwest::{Client, Method, Url}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -10,9 +10,35 @@ use std::time::Duration; use super::jwt::bearer_authorization_value; +const CLIENT_VERSION_HEADER_MAX_LEN: usize = 64; + +fn sanitize_client_version(raw: &str) -> Option { + let sanitized: String = raw + .trim() + .chars() + .filter(|c| matches!(c, '0'..='9' | 'A'..='Z' | 'a'..='z' | '.' | '_' | '+' | '-')) + .take(CLIENT_VERSION_HEADER_MAX_LEN) + .collect(); + + if sanitized.is_empty() { + None + } else { + Some(sanitized) + } +} + fn build_backend_reqwest_client() -> Result { + let mut default_headers = HeaderMap::new(); + if let Some(version) = sanitize_client_version(env!("CARGO_PKG_VERSION")) { + default_headers.insert( + HeaderName::from_static("x-core-version"), + HeaderValue::from_str(&version).context("invalid x-core-version header value")?, + ); + } + // Force rustls for consistent cross-platform TLS behavior. Client::builder() + .default_headers(default_headers) .use_rustls_tls() .http1_only() .timeout(Duration::from_secs(120)) diff --git a/src/api/rest_tests.rs b/src/api/rest_tests.rs index 1e60e04e7..1b425a940 100644 --- a/src/api/rest_tests.rs +++ b/src/api/rest_tests.rs @@ -1,6 +1,13 @@ -use super::key_bytes_from_string; +use super::{key_bytes_from_string, sanitize_client_version, BackendOAuthClient}; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::routing::{get, post}; +use axum::{Json, Router}; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use base64::Engine; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; +use tokio::net::TcpListener; #[test] fn decodes_base64url_no_pad() { @@ -47,7 +54,6 @@ fn rejects_wrong_length() { } use super::user_id_from_profile_payload; -use serde_json::json; #[test] fn extracts_id_from_root() { @@ -128,3 +134,104 @@ fn returns_none_for_non_object_payload() { let payload = json!("just a string"); assert!(user_id_from_profile_payload(&payload).is_none()); } + +#[test] +fn sanitize_client_version_strips_invalid_chars_and_clamps_length() { + let raw = format!(" 1.2.3 (desktop)+build!?{} ", "a".repeat(80)); + let sanitized = sanitize_client_version(&raw).unwrap(); + assert_eq!(sanitized, format!("1.2.3desktop+build{}", "a".repeat(46))); + assert_eq!(sanitized.len(), 64); +} + +#[derive(Clone, Default)] +struct CapturedHeaders { + entries: Arc>>, +} + +impl CapturedHeaders { + fn push(&self, headers: &HeaderMap) { + self.entries.lock().unwrap().push(headers.clone()); + } + + fn take(&self) -> Vec { + self.entries.lock().unwrap().clone() + } +} + +async fn spawn_header_capture_server() -> (String, CapturedHeaders) { + async fn capture_consume( + State(captured): State, + headers: HeaderMap, + ) -> Json { + captured.push(&headers); + Json(json!({ + "success": true, + "data": { "jwtToken": "mock-jwt-token" } + })) + } + + async fn capture_probe( + State(captured): State, + headers: HeaderMap, + ) -> Json { + captured.push(&headers); + Json(json!({ "ok": true })) + } + + let captured = CapturedHeaders::default(); + let app = Router::new() + .route( + "/telegram/login-tokens/{token}/consume", + post(capture_consume), + ) + .route("/probe", get(capture_probe)) + .with_state(captured.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (format!("http://{addr}"), captured) +} + +#[tokio::test] +async fn backend_client_sends_x_core_version_on_auth_requests() { + let (base_url, captured) = spawn_header_capture_server().await; + let client = BackendOAuthClient::new(&base_url).unwrap(); + + let jwt = client.consume_login_token("test-token").await.unwrap(); + assert_eq!(jwt, "mock-jwt-token"); + + let headers = captured.take(); + let request_headers = headers.last().unwrap(); + let version = request_headers + .get("x-core-version") + .and_then(|value| value.to_str().ok()) + .unwrap(); + assert_eq!( + version, + sanitize_client_version(env!("CARGO_PKG_VERSION")).unwrap() + ); +} + +#[tokio::test] +async fn backend_raw_client_inherits_x_core_version_default_header() { + let (base_url, captured) = spawn_header_capture_server().await; + let client = BackendOAuthClient::new(&base_url).unwrap(); + let url = client.url_for("/probe").unwrap(); + + let response = client.raw_client().get(url).send().await.unwrap(); + assert!(response.status().is_success()); + + let headers = captured.take(); + let request_headers = headers.last().unwrap(); + let version = request_headers + .get("x-core-version") + .and_then(|value| value.to_str().ok()) + .unwrap(); + assert_eq!( + version, + sanitize_client_version(env!("CARGO_PKG_VERSION")).unwrap() + ); +}