mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
Send client app version headers on backend requests (#1456)
This commit is contained in:
Generated
+1
-1
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user