Send client app version headers on backend requests (#1456)

This commit is contained in:
Steven Enamakel
2026-05-09 23:46:01 -07:00
committed by GitHub
parent c50ffe7993
commit 887e483790
10 changed files with 335 additions and 24 deletions
Generated
+1 -1
View File
@@ -4520,7 +4520,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openhuman"
version = "0.53.22"
version = "0.53.25"
dependencies = [
"aes-gcm",
"anyhow",
@@ -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<string, string>;
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<string, string>;
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);
});
});
@@ -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),
});
+3 -1
View File
@@ -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,
});
+5 -2
View File
@@ -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<HeadersInit> {
const versionHeaders = await getClientVersionHeaders();
const headers: Record<string, string> = {
'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 });
+44
View File
@@ -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<string | null> | 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<string | null> {
if (!isTauri()) {
return null;
}
if (!tauriVersionPromise) {
tauriVersionPromise = getVersion()
.then(version => sanitizeClientVersion(version))
.catch(() => {
tauriVersionPromise = null;
return null;
});
}
return tauriVersionPromise;
}
export async function getClientVersionHeaders(): Promise<Record<string, string>> {
if (isTauri()) {
const tauriVersion = await getTauriClientVersion();
return tauriVersion ? { 'x-tauri-version': tauriVersion } : {};
}
const webVersion = sanitizeClientVersion(APP_VERSION);
return webVersion ? { 'x-web-version': webVersion } : {};
}
@@ -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<string, string> }>;
};
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',
});
});
+51 -16
View File
@@ -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
? [
+27 -1
View File
@@ -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<String> {
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<Client> {
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))
+109 -2
View File
@@ -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<Mutex<Vec<HeaderMap>>>,
}
impl CapturedHeaders {
fn push(&self, headers: &HeaderMap) {
self.entries.lock().unwrap().push(headers.clone());
}
fn take(&self) -> Vec<HeaderMap> {
self.entries.lock().unwrap().clone()
}
}
async fn spawn_header_capture_server() -> (String, CapturedHeaders) {
async fn capture_consume(
State(captured): State<CapturedHeaders>,
headers: HeaderMap,
) -> Json<Value> {
captured.push(&headers);
Json(json!({
"success": true,
"data": { "jwtToken": "mock-jwt-token" }
}))
}
async fn capture_probe(
State(captured): State<CapturedHeaders>,
headers: HeaderMap,
) -> Json<Value> {
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()
);
}