From 83921fadc0331409cc138a737648a6f1fb20c803 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Sun, 11 Oct 2026 22:51:48 -0300 Subject: [PATCH] feat(http): app-defined request identity and shared cookie jar (fix #3687) - `userAgent` and `requestHeaders` config options to set or remove headers (e.g. Origin) per URL pattern - `Builder` with the same options plus an `on_request` hook - `HttpExt::http().cookie_jar()` exposes the jar to native reqwest clients - unit tests, plus e2e coverage through the api example --- .changes/http-builder.md | 6 + .changes/http-cookie-jar.md | 6 + .changes/http-redirects.md | 10 + .changes/http-request-headers-config.md | 6 + .changes/http-user-agent-config.md | 6 + examples/api/src-tauri/src/cmd.rs | 59 +- examples/api/src-tauri/src/lib.rs | 22 +- examples/api/src-tauri/tauri.conf.json | 24 + examples/api/src/views/Http.svelte | 125 +++++ packages/api-e2e/README.md | 50 +- packages/api-e2e/test/specs/http.spec.ts | 163 ++++++ plugins/http/README.md | 62 +++ plugins/http/src/commands.rs | 315 ++--------- plugins/http/src/config.rs | 33 ++ plugins/http/src/error.rs | 5 + plugins/http/src/lib.rs | 354 +++++++++--- plugins/http/src/redirect.rs | 676 +++++++++++++++++++++++ plugins/http/src/request_headers.rs | 635 +++++++++++++++++++++ plugins/http/src/reqwest_cookie_store.rs | 340 ++++++++++-- plugins/http/src/scope.rs | 2 +- 20 files changed, 2484 insertions(+), 415 deletions(-) create mode 100644 .changes/http-builder.md create mode 100644 .changes/http-cookie-jar.md create mode 100644 .changes/http-redirects.md create mode 100644 .changes/http-request-headers-config.md create mode 100644 .changes/http-user-agent-config.md create mode 100644 plugins/http/src/redirect.rs create mode 100644 plugins/http/src/request_headers.rs diff --git a/.changes/http-builder.md b/.changes/http-builder.md new file mode 100644 index 0000000000..f420659abe --- /dev/null +++ b/.changes/http-builder.md @@ -0,0 +1,6 @@ +--- +http: minor +http-js: minor +--- + +Added the plugin `Builder`, which sets the `userAgent` and `requestHeaders` options in Rust, plus an `on_request` hook that can change the headers of every request. `init()` is now equivalent to `Builder::new().build()`. diff --git a/.changes/http-cookie-jar.md b/.changes/http-cookie-jar.md new file mode 100644 index 0000000000..9f30a17349 --- /dev/null +++ b/.changes/http-cookie-jar.md @@ -0,0 +1,6 @@ +--- +http: minor +http-js: minor +--- + +Added the `HttpExt` trait. `app.http().cookie_jar()` returns the plugin's `CookieJar`, which can be passed to your own `reqwest` clients to share the webview's session, and can be cleared or saved. diff --git a/.changes/http-redirects.md b/.changes/http-redirects.md new file mode 100644 index 0000000000..c49582372b --- /dev/null +++ b/.changes/http-redirects.md @@ -0,0 +1,10 @@ +--- +http: minor +http-js: minor +--- + +Redirects are now followed by the plugin instead of `reqwest`, so the `requestHeaders` rules and the `on_request` hook apply again to each redirect target. Other changes to how redirects are followed: + +- `Authorization`, `Cookie` and `Proxy-Authorization` headers set by the frontend are dropped once a redirect leaves the origin of the request, as the fetch spec requires. The origin includes the scheme, so this now also applies to a redirect from `http://` to `https://` on the same host, where `reqwest` used to keep them. Request such URLs over `https://` directly. +- Exceeding `maxRedirections` now fails with `too many redirects, the redirect to was not followed` (the new `Error::TooManyRedirects`). +- `Error` is now `#[non_exhaustive]`, so a `match` on it needs a wildcard arm. diff --git a/.changes/http-request-headers-config.md b/.changes/http-request-headers-config.md new file mode 100644 index 0000000000..adf28c8d39 --- /dev/null +++ b/.changes/http-request-headers-config.md @@ -0,0 +1,6 @@ +--- +http: minor +http-js: minor +--- + +Added the `requestHeaders` configuration option, which sets or removes headers, including `Origin`, on requests to the URLs matching a pattern. By default a rule only replaces headers the frontend did not set; use `"mode": "override"` to always apply it. diff --git a/.changes/http-user-agent-config.md b/.changes/http-user-agent-config.md new file mode 100644 index 0000000000..30b289f603 --- /dev/null +++ b/.changes/http-user-agent-config.md @@ -0,0 +1,6 @@ +--- +http: minor +http-js: minor +--- + +Added the `userAgent` configuration option, which replaces the default `tauri-plugin-http/` User-Agent. A User-Agent set by the frontend still takes precedence. diff --git a/examples/api/src-tauri/src/cmd.rs b/examples/api/src-tauri/src/cmd.rs index 734552c637..93ab935e5b 100644 --- a/examples/api/src-tauri/src/cmd.rs +++ b/examples/api/src-tauri/src/cmd.rs @@ -2,8 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +use std::sync::Mutex; + use serde::Deserialize; -use tauri::command; +use tauri::{AppHandle, command}; +use tauri_plugin_http::{ + HttpExt, RequestContext, + reqwest::header::{HeaderMap, HeaderValue, ORIGIN}, +}; #[derive(Debug, Deserialize)] #[allow(unused)] @@ -22,3 +28,54 @@ pub fn perform_request(endpoint: String, body: RequestBody) -> String { println!("{} {:?}", endpoint, body); "message response".into() } + +/// The URLs under `/identity/` the http plugin's `on_request` hook ran for. +static HTTP_HOOK_URLS: Mutex> = Mutex::new(Vec::new()); + +/// The http plugin's `on_request` hook: requests to `/identity/hook/` get a header from it, and its +/// `Origin` replaces the one from the `requestHeaders` rules, which apply before it. +pub fn http_on_request(ctx: &RequestContext<'_>, headers: &mut HeaderMap) { + let path = ctx.url().path(); + if path.starts_with("/identity/") { + HTTP_HOOK_URLS.lock().unwrap().push(ctx.url().to_string()); + } + if path.starts_with("/identity/hook/") { + headers.insert(ORIGIN, HeaderValue::from_static("https://hook.example.com")); + if let Ok(label) = HeaderValue::from_str(ctx.webview_label()) { + headers.insert("x-hook-webview-label", label); + } + if let Some(Ok(origin)) = ctx.webview_origin().map(HeaderValue::from_str) { + headers.insert("x-hook-webview-origin", origin); + } + } +} + +#[command] +pub fn http_hook_urls() -> Vec { + HTTP_HOOK_URLS.lock().unwrap().clone() +} + +/// Sends a request from Rust with the http plugin's cookie jar and returns the `Cookie` header the +/// example's echo server received. +#[command] +pub async fn http_native_cookie(app: AppHandle) -> Result, String> { + let client = tauri_plugin_http::reqwest::Client::builder() + .cookie_provider(app.http().cookie_jar()) + .build() + .map_err(|e| e.to_string())?; + let response = client + .get("http://localhost:3003/native") + .send() + .await + .map_err(|e| e.to_string())?; + Ok(response + .headers() + .get("cookie") + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string)) +} + +#[command] +pub fn http_clear_cookies(app: AppHandle) { + app.http().cookie_jar().clear(); +} diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index 118ed685b9..0220506957 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -48,7 +48,11 @@ pub fn run() { .plugin(tauri_plugin_biometric::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_http::init()) + .plugin( + tauri_plugin_http::Builder::new() + .on_request(cmd::http_on_request) + .build(), + ) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_process::init()) @@ -136,6 +140,19 @@ pub fn run() { if let Ok(mut request) = server.recv() { let mut body = Vec::new(); let _ = request.as_reader().read_to_end(&mut body); + + // `.../redirect/` redirects to `/` + if let Some((_, target)) = request.url().split_once("/redirect/") { + let location = tiny_http::Header::from_bytes( + &b"Location"[..], + format!("/{target}").as_bytes(), + ) + .unwrap(); + let _ = request + .respond(tiny_http::Response::empty(302).with_header(location)); + continue; + } + let mut headers = request.headers().to_vec(); if !headers.iter().any(|header| header.field == tiny_http::HeaderField::from_bytes(b"Cookie").unwrap()) { @@ -195,6 +212,9 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ cmd::log_operation, cmd::perform_request, + cmd::http_hook_urls, + cmd::http_native_cookie, + cmd::http_clear_cookies, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json index e30e3364a8..f72ec4a1f7 100644 --- a/examples/api/src-tauri/tauri.conf.json +++ b/examples/api/src-tauri/tauri.conf.json @@ -37,6 +37,30 @@ } }, "plugins": { + "http": { + "userAgent": "tauri-api-example/1.0", + "requestHeaders": [ + { + "url": "http://localhost:3003/identity/*", + "headers": { + "Origin": "https://desktop.example.com", + "X-Identity": "config" + } + }, + { + "url": "http://localhost:3003/identity/override/*", + "headers": { + "User-Agent": "override/1.0", + "X-Identity": "override" + }, + "mode": "override" + }, + { + "url": "http://localhost:3003/identity/no-origin/*", + "headers": { "Origin": null } + } + ] + }, "cli": { "description": "Tauri API example", "args": [ diff --git a/examples/api/src/views/Http.svelte b/examples/api/src/views/Http.svelte index 5c7c3c4925..53e3f687a6 100644 --- a/examples/api/src/views/Http.svelte +++ b/examples/api/src/views/Http.svelte @@ -1,5 +1,6 @@
@@ -101,3 +179,50 @@

+ +
+ +

Request identity

+ +
+ + + + +
+
+ + +
+ +

Cookie jar shared with Rust

+ +
+ + + +
+
+ diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md index 745111307a..eddd6c6559 100644 --- a/packages/api-e2e/README.md +++ b/packages/api-e2e/README.md @@ -64,31 +64,31 @@ The example only registers each plugin on the platforms it supports, so the suit split three ways: desktop-only plugins are skipped on mobile, mobile-only plugins are skipped on desktop, and the rest run everywhere with the odd test gated. -| Plugin | Coverage | On mobile | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. | -| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. | -| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. | -| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. | -| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. | -| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). | -| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. | -| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. | -| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. | -| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. | -| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. | -| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. | -| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. | -| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. | -| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. | -| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. | -| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. | -| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. | -| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). | -| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). | -| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. | -| `biometric` | The `passcodeNotSet` rejection when `allowDeviceCredential` is set and the device has no lock screen (checked over `adb`), the only path that skips the prompt. | Android only; skipped on desktop. | -| mobile-only plugins | `barcode-scanner`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. | +| Plugin | Coverage | On mobile | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. | +| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. | +| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. | +| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. | +| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. | +| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). | +| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. | +| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar (also shared with Rust), configured request identity rules and the `on_request` hook, abort, scope, failures. | Same. | +| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. | +| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. | +| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. | +| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. | +| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. | +| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. | +| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. | +| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. | +| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. | +| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. | +| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). | +| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). | +| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. | +| `biometric` | The `passcodeNotSet` rejection when `allowDeviceCredential` is set and the device has no lock screen (checked over `adb`), the only path that skips the prompt. | Android only; skipped on desktop. | +| mobile-only plugins | `barcode-scanner`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. | The notification permission specs are desktop-only: a mobile app starts out ungranted and `requestPermission` puts up a system dialog the session would then block on. The rest of the diff --git a/packages/api-e2e/test/specs/http.spec.ts b/packages/api-e2e/test/specs/http.spec.ts index cd283200a3..f0dc018881 100644 --- a/packages/api-e2e/test/specs/http.spec.ts +++ b/packages/api-e2e/test/specs/http.spec.ts @@ -136,6 +136,169 @@ describePlugin('http', () => { expect(message).toMatch(/url not allowed on the configured scope/) }) + // The example configures `userAgent` and `requestHeaders` rules for + // `/identity/*` paths in its tauri.conf.json, and an `on_request` hook that + // handles `/identity/hook/*`. The echo server mirrors the headers it received. + describe('request identity', () => { + async function echoedHeaders( + url: string, + headers: Record = {} + ): Promise> { + return await tauri( + async (api, url, headers) => { + const response = await api.http.fetch(url, { headers }) + await response.text() + const names = [ + 'user-agent', + 'origin', + 'x-identity', + 'x-hook-webview-label', + 'x-hook-webview-origin' + ] + return Object.fromEntries( + names.map((name) => [name, response.headers.get(name)]) + ) + }, + url, + headers + ) + } + + it('sends the configured user agent and the webview origin by default', async () => { + const headers = await echoedHeaders(`${echoServer}/plain`) + expect(headers['user-agent']).toBe('tauri-api-example/1.0') + expect(headers.origin).toMatch( + /^(tauri:\/\/localhost|http:\/\/tauri\.localhost)$/ + ) + expect(headers['x-identity']).toBeNull() + }) + + it('keeps the user agent set by the frontend', async () => { + const headers = await echoedHeaders(`${echoServer}/plain`, { + 'user-agent': 'web/1.0' + }) + expect(headers['user-agent']).toBe('web/1.0') + }) + + it('applies matching rules without replacing frontend headers', async () => { + let headers = await echoedHeaders(`${echoServer}/identity/rule`) + expect(headers.origin).toBe('https://desktop.example.com') + expect(headers['x-identity']).toBe('config') + + headers = await echoedHeaders(`${echoServer}/identity/rule`, { + 'x-identity': 'web' + }) + expect(headers['x-identity']).toBe('web') + }) + + it('override rules replace frontend headers, later rules win', async () => { + const headers = await echoedHeaders(`${echoServer}/identity/override/x`, { + 'user-agent': 'web/1.0', + 'x-identity': 'web' + }) + expect(headers['user-agent']).toBe('override/1.0') + expect(headers['x-identity']).toBe('override') + // set by the first rule, which also matches + expect(headers.origin).toBe('https://desktop.example.com') + }) + + it('rules can remove the origin', async () => { + const headers = await echoedHeaders(`${echoServer}/identity/no-origin/x`) + expect(headers.origin).toBeNull() + expect(headers['x-identity']).toBe('config') + }) + + it('rules and the hook apply again to each redirect target', async () => { + // the rule for `/identity/*` matches the redirect, not its target + let headers = await echoedHeaders( + `${echoServer}/identity/rule/redirect/plain` + ) + expect(headers['x-identity']).toBeNull() + expect(headers.origin).toMatch( + /^(tauri:\/\/localhost|http:\/\/tauri\.localhost)$/ + ) + + // a target the rule matches gets its headers + headers = await echoedHeaders( + `${echoServer}/plain/redirect/identity/rule` + ) + expect(headers['x-identity']).toBe('config') + expect(headers.origin).toBe('https://desktop.example.com') + + headers = await echoedHeaders( + `${echoServer}/identity/hook/redirect/plain` + ) + expect(headers['x-hook-webview-label']).toBeNull() + expect(headers['x-identity']).toBeNull() + }) + + it('the on_request hook runs after the rules', async () => { + const headers = await echoedHeaders(`${echoServer}/identity/hook/x`) + expect(headers.origin).toBe('https://hook.example.com') + expect(headers['x-identity']).toBe('config') + expect(headers['x-hook-webview-label']).toBe('main') + expect(headers['x-hook-webview-origin']).toMatch( + /^(tauri:\/\/localhost|http:\/\/tauri\.localhost)$/ + ) + }) + + it('the on_request hook does not run for URLs outside the scope', async () => { + const allowed = `${echoServer}/identity/hook/allowed` + const denied = 'http://localhost:3999/identity/hook/denied' + await echoedHeaders(allowed) + const message = await tauriError( + (api, url) => api.http.fetch(url), + denied + ) + expect(message).toMatch(/url not allowed on the configured scope/) + + const urls = await tauri((api) => + api.core.invoke('http_hook_urls') + ) + expect(urls).toContain(allowed) + expect(urls).not.toContain(denied) + }) + }) + + describe('cookie jar shared with Rust', () => { + it('a native client sends the cookies set on webview requests', async () => { + const cookie = await tauri(async (api, url) => { + // gets the cookie set, or replays the one an earlier request stored + await (await api.http.fetch(`${url}/cookies`)).text() + return await api.core.invoke('http_native_cookie') + }, echoServer) + expect(cookie).toContain('session-token=test-value') + }) + + it('clearing the jar from Rust affects webview requests', async () => { + const result = await tauri(async (api, url) => { + await api.core.invoke('http_clear_cookies') + // nothing is sent, so the echo server sets the cookie again + const cleared = await api.http.fetch(`${url}/cookies`) + await cleared.text() + + await api.core.invoke('http_clear_cookies') + // the cookie the echo server sets on this native request lands in the + // shared jar, so the next webview request sends it + const nativeAfterClear = await api.core.invoke( + 'http_native_cookie' + ) + const replayed = await api.http.fetch(`${url}/cookies`) + await replayed.text() + return { + sentAfterClear: cleared.headers.get('cookie'), + setAgain: cleared.headers.get('set-cookie'), + nativeAfterClear, + replayed: replayed.headers.get('cookie') + } + }, echoServer) + expect(result.sentAfterClear).toBeNull() + expect(result.setAgain).toMatch(/^session-token=test-value/) + expect(result.nativeAfterClear).toBeNull() + expect(result.replayed).toContain('session-token=test-value') + }) + }) + it('network failures reject', async () => { // Every in-scope origin is reachable, so route the request through a proxy // nothing listens on to force a connection error. diff --git a/plugins/http/README.md b/plugins/http/README.md index 32c619e1d9..4df33049b8 100644 --- a/plugins/http/README.md +++ b/plugins/http/README.md @@ -66,6 +66,68 @@ const response = await fetch('http://localhost:3003/users/2', { }) ``` +### Request identity + +By default, requests are sent with a `tauri-plugin-http/` User-Agent and with the webview's origin, which depends on the platform (`tauri://localhost` or `http://tauri.localhost`) and on the dev server URL during development. To send a fixed identity instead, configure `userAgent` and `requestHeaders` in `tauri.conf.json`: + +```json +{ + "plugins": { + "http": { + "userAgent": "my-app/1.0", + "requestHeaders": [ + { + "url": "https://api.example.com/*", + "headers": { "Origin": "https://desktop.example.com" } + }, + { + "url": "https://*.thirdparty.example/*", + "headers": { "Origin": null }, + "mode": "override" + } + ] + } + } +} +``` + +Every rule whose `url` pattern matches applies, in order. A string sets the header and `null` removes it. By default a rule only replaces headers the frontend did not set; `"mode": "override"` always applies it. Rules cannot set `Host`, `Content-Length`, `Transfer-Encoding` or `Cookie`. + +The patterns only choose which requests get the headers: they do not grant access, which is still controlled by the `http:default` scope. The plugin applies the rules again to every redirect it follows, matched against the redirect target, so headers set for one URL are not sent to another. Since rules can set headers such as `Origin` that the frontend is not allowed to send, review changes to them like a capability change. + +The same options are available in Rust, together with a hook for policies the configuration cannot express: + +```rust +use tauri_plugin_http::reqwest::header::{HeaderValue, ORIGIN}; + +tauri::Builder::default() + .plugin( + tauri_plugin_http::Builder::new() + .user_agent("my-app/1.0") + .on_request(|ctx, headers| { + if ctx.url().host_str() == Some("api.example.com") && !ctx.frontend_set(&ORIGIN) { + headers.insert(ORIGIN, HeaderValue::from_static("https://desktop.example.com")); + } + }) + .build(), + ) +``` + +### Sharing cookies with Rust + +The cookie jar used by webview requests can be given to your own `reqwest` clients, so both share one session: + +```rust +use tauri_plugin_http::HttpExt; + +let jar = app.http().cookie_jar(); +let client = tauri_plugin_http::reqwest::Client::builder() + .cookie_provider(jar.clone()) + .build()?; + +jar.clear(); // e.g. on logout +``` + ## Contributing PRs accepted. Please make sure to read the Contributing Guide before making a pull request. diff --git a/plugins/http/src/commands.rs b/plugins/http/src/commands.rs index fe5c3bcdee..f2fc438cfd 100644 --- a/plugins/http/src/commands.rs +++ b/plugins/http/src/commands.rs @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::{future::Future, pin::Pin, str::FromStr, sync::Arc, time::Duration}; +use std::{ + collections::HashSet, future::Future, pin::Pin, str::FromStr, sync::Arc, time::Duration, +}; use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header}; use reqwest::{NoProxy, redirect::Policy}; @@ -16,16 +18,11 @@ use tauri::{ use tokio::sync::oneshot::{Receiver, Sender, channel}; use crate::{ - Error, Http, Result, + Error, Http, RequestContext, Result, + redirect::{DEFAULT_MAX_REDIRECTIONS, Redirects, send}, scope::{Entry, Scope}, }; -const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); - -/// Matches the default [`reqwest`] redirect policy, used when the frontend does not -/// configure `maxRedirections`. -const DEFAULT_MAX_REDIRECTIONS: usize = 10; - struct ReqwestResponse(reqwest::Response); impl tauri::Resource for ReqwestResponse {} @@ -178,56 +175,6 @@ fn attach_proxy( Ok(builder) } -/// Builds the redirect policy for the request. -/// -/// When `scope` is [`Some`], **every** hop is checked against it. Validating only the URL the -/// frontend asked for is not enough: a server on an allowed origin can answer with a redirect to -/// any other origin (an open redirect, or a server the attacker controls), and following it would -/// give the webview access to a URL the scope denies. -/// -/// That check is opt-in through the `scopeRedirects` configuration because it breaks applications -/// that rely on being redirected outside of their scope. -// TODO(v3): always check the scope and take it by value instead of `Option` -fn redirect_policy(scope: Option, max_redirections: Option) -> Policy { - if max_redirections == Some(0) { - return Policy::none(); - } - - let max_redirections = max_redirections.unwrap_or(DEFAULT_MAX_REDIRECTIONS); - - let Some(scope) = scope else { - return Policy::limited(max_redirections); - }; - - Policy::custom(move |attempt| { - // the first URL in `previous` is the initial request, so it must be excluded - if attempt.previous().len() > max_redirections { - attempt.error("too many redirects") - } else if scope.is_allowed(attempt.url()) { - attempt.follow() - } else { - let url = attempt.url().clone(); - attempt.error(Error::UrlNotAllowed(url)) - } - }) -} - -/// [`reqwest`] wraps the error returned by the redirect policy, and its `Display` impl does not -/// include the source, so we unwrap our own scope error to keep the reason visible to the frontend. -fn map_request_error(error: reqwest::Error) -> Error { - if error.is_redirect() { - let mut source = std::error::Error::source(&error); - while let Some(err) = source { - if let Some(Error::UrlNotAllowed(url)) = err.downcast_ref::() { - return Error::UrlNotAllowed(url.clone()); - } - source = err.source(); - } - } - - Error::Network(error) -} - #[command] pub async fn fetch( webview: Webview, @@ -272,6 +219,9 @@ pub async fn fetch( match scheme { "http" | "https" => { + // taken before the plugin adds its own headers + let frontend_headers: HashSet = headers.keys().cloned().collect(); + let scope = Scope::new( command_scope .allows() @@ -317,8 +267,14 @@ pub async fn fetch( builder = builder.connect_timeout(Duration::from_millis(timeout)); } - let scope = state.config.scope_redirects.then_some(scope); - builder = builder.redirect(redirect_policy(scope, max_redirections)); + // followed by `send`, which computes the headers of every hop + builder = builder.redirect(Policy::none()); + let redirects = Redirects { + scope: state.config.scope_redirects.then_some(scope), + max: max_redirections.unwrap_or(DEFAULT_MAX_REDIRECTIONS), + // the default of `reqwest::ClientBuilder::referer` + referer: true, + }; if let Some(proxy_config) = proxy { builder = attach_proxy(proxy_config, builder)?; @@ -329,8 +285,6 @@ pub async fn fetch( builder = builder.cookie_provider(state.cookies_jar.clone()); } - let mut request = builder.build()?.request(method.clone(), url); - // POST and PUT requests should always have a 0 length content-length, // if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch if data.is_none() && matches!(method, Method::POST | Method::PUT) { @@ -343,41 +297,45 @@ pub async fn fetch( headers.append(header::ACCEPT_ENCODING, HeaderValue::from_str("identity")?); } - if !headers.contains_key(header::USER_AGENT) { - headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?); - } - - // ensure we have an Origin header set - if (cfg!(not(feature = "unsafe-headers")) || !headers.contains_key(header::ORIGIN)) - && let Ok(url) = webview.url() - { + let webview_origin = webview.url().ok().map(|url| { // The url crate returns OpaqueOrigin for tauri://localhost which serializes to "null" - let origin = if url.scheme() == "tauri" { + if url.scheme() == "tauri" { "tauri://localhost".to_string() } else { url.origin().ascii_serialization() - }; - headers.append(header::ORIGIN, HeaderValue::from_str(&origin)?); - } - - // In case empty origin is passed, remove it. Some services do not like Origin header - // so this way we can remove it in explicit way. The default behaviour is still to set it - if cfg!(feature = "unsafe-headers") - && headers.get(header::ORIGIN) == Some(&HeaderValue::from_static("")) - { - headers.remove(header::ORIGIN); + } + }); + let webview_label = webview.label().to_string(); + let request_headers = state.request_headers.clone(); + let add_headers = move |url: &url::Url, + method: &Method, + frontend_headers: &HashSet, + headers: &mut HeaderMap| { + let ctx = RequestContext::new( + url, + method, + &webview_label, + webview_origin.as_deref(), + frontend_headers, + ); + request_headers.apply(&ctx, headers) }; + let client = builder.build()?; + let mut request = client.request(method, url); if let Some(data) = data { request = request.body(data); } - - request = request.headers(headers); - - #[cfg(feature = "tracing")] - tracing::trace!("{:?}", request); - - let fut = async move { request.send().await.map_err(map_request_error) }; + let request = request.build()?; + + let fut = send( + client, + request, + headers, + frontend_headers, + redirects, + add_headers, + ); let mut resources_table = webview.resources_table(); let rid = resources_table.add_request(Box::pin(fut)); @@ -548,184 +506,3 @@ fn is_unsafe_header(header: &HeaderName) -> bool { lower.starts_with("proxy-") || lower.starts_with("sec-") } } - -#[cfg(test)] -mod tests { - use std::{ - io::{BufRead, BufReader, Write}, - net::TcpListener, - sync::Arc, - }; - - use super::*; - - /// Test server with an open redirect: - /// - /// - `/redirect-external` answers a 302 to `http://127.0.0.1:{port}/secret`, which is a - /// different origin as far as the scope is concerned - the tests allow `localhost`; - /// - `/redirect/{n}` answers a 302 to `/redirect/{n-1}` on `localhost`, and `/redirect/0` - /// answers a 302 to `/target`, so the whole chain stays in scope; - /// - anything else answers a 200, so a bypass shows up as a successful response. - fn spawn_server() -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - - std::thread::spawn(move || { - for stream in listener.incoming() { - let mut stream = stream.unwrap(); - - // the whole request must be consumed, otherwise closing the connection - // with unread data queued resets it before the client reads the response - let mut reader = BufReader::new(stream.try_clone().unwrap()); - let mut request_line = String::new(); - reader.read_line(&mut request_line).unwrap(); - loop { - let mut header = String::new(); - reader.read_line(&mut header).unwrap(); - if matches!(header.as_str(), "" | "\r\n" | "\n") { - break; - } - } - - let path = request_line.split(' ').nth(1).unwrap_or("/"); - let location = match path.strip_prefix("/redirect/") { - Some("0") => Some(format!("http://localhost:{port}/target")), - Some(remaining) => Some(format!( - "http://localhost:{port}/redirect/{}", - remaining.parse::().unwrap() - 1 - )), - None if path == "/redirect-external" => { - Some(format!("http://127.0.0.1:{port}/secret")) - } - None => None, - }; - - let response = match location { - Some(location) => format!( - "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - ), - None => { - "HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nsecret" - .to_string() - } - }; - - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); - } - }); - - port - } - - fn get( - url: &str, - scope: Option, - max_redirections: Option, - ) -> Result { - let client = reqwest::ClientBuilder::new() - .redirect(redirect_policy(scope, max_redirections)) - .build() - .unwrap(); - let request = client.get(url); - tauri::async_runtime::block_on( - async move { request.send().await.map_err(map_request_error) }, - ) - } - - fn localhost_scope(port: u16) -> Option { - let entry = Arc::new(format!("http://localhost:{port}/*").parse().unwrap()); - Some(Scope::new(vec![entry], Vec::new())) - } - - #[test] - fn redirect_outside_of_scope_is_denied() { - let port = spawn_server(); - - let err = get( - &format!("http://localhost:{port}/redirect-external"), - localhost_scope(port), - None, - ) - .unwrap_err(); - - match err { - Error::UrlNotAllowed(url) => { - assert_eq!(url.as_str(), format!("http://127.0.0.1:{port}/secret")) - } - e => panic!("expected the redirect to be denied by the scope, got {e:?}"), - } - } - - #[test] - fn redirect_outside_of_scope_is_followed_when_not_configured() { - let port = spawn_server(); - - // `scopeRedirects` is disabled, so only the URL requested by the frontend is checked - let response = get( - &format!("http://localhost:{port}/redirect-external"), - None, - None, - ) - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - } - - #[test] - fn redirect_denied_by_the_scope_is_denied() { - let port = spawn_server(); - let allow = Arc::new(format!("http://localhost:{port}/*").parse().unwrap()); - let deny = Arc::new(format!("http://localhost:{port}/target").parse().unwrap()); - let scope = Some(Scope::new(vec![allow], vec![deny])); - - let err = get(&format!("http://localhost:{port}/redirect/0"), scope, None).unwrap_err(); - - assert!(matches!(err, Error::UrlNotAllowed(_))); - } - - #[test] - fn redirect_inside_of_scope_is_followed() { - let port = spawn_server(); - - let response = get( - &format!("http://localhost:{port}/redirect/2"), - localhost_scope(port), - None, - ) - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.url().path(), "/target"); - } - - #[test] - fn max_redirections_is_enforced() { - let port = spawn_server(); - - for scope in [localhost_scope(port), None] { - let err = get( - &format!("http://localhost:{port}/redirect/5"), - scope, - Some(2), - ) - .unwrap_err(); - - assert!(matches!(err, Error::Network(e) if e.is_redirect())); - } - } - - #[test] - fn zero_max_redirections_does_not_follow() { - let port = spawn_server(); - - let response = get( - &format!("http://localhost:{port}/redirect/0"), - localhost_scope(port), - Some(0), - ) - .unwrap(); - - assert_eq!(response.status(), StatusCode::FOUND); - } -} diff --git a/plugins/http/src/config.rs b/plugins/http/src/config.rs index 02f4e92852..c33a28fdc8 100644 --- a/plugins/http/src/config.rs +++ b/plugins/http/src/config.rs @@ -4,6 +4,8 @@ use serde::Deserialize; +use crate::RequestHeaderRule; + /// HTTP plugin configuration, defined on the `plugins > http` object of your `tauri.conf.json`. #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -23,6 +25,22 @@ pub struct Config { // TODO(v3): enforce the scope on redirects by default and remove this option #[serde(default)] pub scope_redirects: bool, + /// The `User-Agent` sent when the frontend does not set one. Defaults to + /// `tauri-plugin-http/`. + /// + /// Overridden by [`Builder::user_agent`](crate::Builder::user_agent). + #[serde(default)] + pub user_agent: Option, + /// Headers to set or remove on requests to the URLs matching a pattern, applied in order after + /// the plugin's default `User-Agent` and `Origin`. See [`RequestHeaderRule`]. + /// + /// The patterns do not change the scope: they only decide which allowed requests get the + /// headers. Rules can set headers the frontend is not allowed to send, such as `Origin`, for + /// any destination they match, so review changes to them like a capability change. + /// + /// Overridden by [`Builder::request_headers`](crate::Builder::request_headers). + #[serde(default)] + pub request_headers: Vec, } #[cfg(test)] @@ -43,5 +61,20 @@ mod tests { // a typo must not silently disable the scope check assert!(serde_json::from_str::(r#"{ "scopeRedirect": true }"#).is_err()); + + let config: Config = serde_json::from_str( + r#"{ + "userAgent": "my-app/1.0", + "requestHeaders": [ + { "url": "https://api.example.com/*", "headers": { "Origin": "https://desktop.example.com" } }, + { "url": "https://*.thirdparty.example/*", "headers": { "Origin": null }, "mode": "override" } + ] + }"#, + ) + .unwrap(); + assert_eq!(config.user_agent.as_deref(), Some("my-app/1.0")); + assert_eq!(config.request_headers.len(), 2); + + assert!(serde_json::from_str::(r#"{ "requestHeader": [] }"#).is_err()); } } diff --git a/plugins/http/src/error.rs b/plugins/http/src/error.rs index 2b974e692b..bac56928a3 100644 --- a/plugins/http/src/error.rs +++ b/plugins/http/src/error.rs @@ -9,6 +9,7 @@ use url::Url; /// /// The error is serialized to the frontend as its [`Display`](std::fmt::Display) string. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum Error { /// JSON serialization or deserialization error. #[error(transparent)] @@ -37,6 +38,10 @@ pub enum Error { /// redirect target that is not allowed by the scope. #[error("url not allowed on the configured scope: {0}")] UrlNotAllowed(Url), + /// The response redirected more times than the request's `maxRedirections` allows (10 by + /// default). Holds the redirect target that was not followed. + #[error("too many redirects, the redirect to {0} was not followed")] + TooManyRedirects(Url), /// Failed to parse a URL. #[error(transparent)] UrlParseError(#[from] url::ParseError), diff --git a/plugins/http/src/lib.rs b/plugins/http/src/lib.rs index e6db850f07..4e83f9c57f 100644 --- a/plugins/http/src/lib.rs +++ b/plugins/http/src/lib.rs @@ -53,24 +53,40 @@ //! { //! "plugins": { //! "http": { -//! "scopeRedirects": true +//! "scopeRedirects": true, +//! "userAgent": "my-app/1.0", +//! "requestHeaders": [ +//! { +//! "url": "https://api.example.com/*", +//! "headers": { "Origin": "https://desktop.example.com" } +//! } +//! ] //! } //! } //! } //! ``` +//! +//! The same options, and an [`on_request`](Builder::on_request) hook for policies the configuration +//! cannot express, can be set in Rust with the plugin [`Builder`]. pub use reqwest; +use reqwest::header::HeaderMap; use tauri::{ Manager, Runtime, - plugin::{Builder, TauriPlugin}, + plugin::{Builder as PluginBuilder, TauriPlugin}, }; pub use config::Config; pub use error::{Error, Result}; +pub use request_headers::{HeaderRuleMode, RequestContext, RequestHeaderRule}; +#[cfg(feature = "cookies")] +pub use reqwest_cookie_store::CookieJar; mod commands; mod config; mod error; +mod redirect; +mod request_headers; #[cfg(feature = "cookies")] mod reqwest_cookie_store; mod scope; @@ -78,13 +94,43 @@ mod scope; #[cfg(feature = "cookies")] const COOKIES_FILENAME: &str = ".cookies"; -pub(crate) struct Http { +/// Access to the plugin's state, see [`HttpExt`]. +pub struct Http { pub(crate) config: Config, + pub(crate) request_headers: std::sync::Arc, + #[cfg(feature = "cookies")] + cookies_jar: std::sync::Arc, +} + +impl Http { + /// The cookie jar used by every request sent from the webview. + /// + /// Give it to your own [`reqwest`] clients to share the webview's session with them; see + /// [`CookieJar`]. #[cfg(feature = "cookies")] - cookies_jar: std::sync::Arc, + pub fn cookie_jar(&self) -> std::sync::Arc { + self.cookies_jar.clone() + } } -/// Initializes the plugin. +/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the HTTP +/// plugin. +pub trait HttpExt { + /// The plugin's state. + /// + /// # Panics + /// + /// Panics if the plugin is not registered. + fn http(&self) -> &Http; +} + +impl> HttpExt for T { + fn http(&self) -> &Http { + self.state::().inner() + } +} + +/// Initializes the plugin with the default options, the same as `Builder::new().build()`. /// /// The plugin reads its [`Config`] from the `plugins > http` object of the `tauri.conf.json` file; /// when that object is missing, [`Config::default`] is used. @@ -95,66 +141,246 @@ pub(crate) struct Http { /// /// Register it on the Tauri builder with `.plugin(tauri_plugin_http::init())`. pub fn init() -> TauriPlugin> { - Builder::>::new("http") - .setup(|app, api| { - #[cfg(feature = "cookies")] - let cookies_jar = { - use crate::reqwest_cookie_store::*; - use std::fs::File; - use std::io::BufReader; - - let cache_dir = app.path().app_cache_dir()?; - std::fs::create_dir_all(&cache_dir)?; - - let path = cache_dir.join(COOKIES_FILENAME); - let file = File::options() - .create(true) - .append(true) - .read(true) - .open(&path)?; - - let reader = BufReader::new(file); - CookieStoreMutex::load(path.clone(), reader).unwrap_or_else(|_e| { - #[cfg(feature = "tracing")] - tracing::warn!( - "failed to load cookie store: {_e}, falling back to empty store" - ); - CookieStoreMutex::new(path, Default::default()) - }) - }; - - let state = Http { - config: api.config().clone().unwrap_or_default(), + Builder::new().build() +} + +/// The plugin builder. +/// +/// Options set here take precedence over the same options in the [`Config`]. +/// +/// ```rust,no_run +/// use tauri_plugin_http::reqwest::header::{HeaderValue, ORIGIN}; +/// +/// # fn f(builder: tauri::Builder) -> tauri::Builder { +/// builder +/// .plugin( +/// tauri_plugin_http::Builder::new() +/// .user_agent("my-app/1.0") +/// .on_request(|ctx, headers| { +/// if ctx.url().host_str() == Some("api.example.com") && !ctx.frontend_set(&ORIGIN) { +/// headers.insert(ORIGIN, HeaderValue::from_static("https://desktop.example.com")); +/// } +/// }) +/// .build(), +/// ) +/// # } +/// ``` +#[derive(Default)] +pub struct Builder { + user_agent: Option, + request_headers: Option>, + on_request: Option, +} + +impl Builder { + /// Creates a builder with the default options. + pub fn new() -> Self { + Self::default() + } + + /// Sets the `User-Agent` sent when the frontend does not set one, replacing + /// [`Config::user_agent`]. + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent.replace(user_agent.into()); + self + } + + /// Sets the rules adding or removing headers on webview requests, replacing + /// [`Config::request_headers`]. + pub fn request_headers(mut self, rules: impl IntoIterator) -> Self { + self.request_headers.replace(rules.into_iter().collect()); + self + } + + /// Sets a hook that can change the headers of every request sent from the webview. + /// + /// The hook runs once the request is allowed by the scope, after the plugin's default + /// `User-Agent` and `Origin` and the [request header rules](Self::request_headers) were applied, + /// so it sees and can change all of them. It runs again for every redirect the plugin follows, + /// with the redirect target as [`RequestContext::url`] and the headers the frontend sent, so + /// what it adds for one URL is not sent to another. + /// + /// The hook can set any header for any destination, including the ones the frontend is not + /// allowed to send, such as `Origin`. Misrepresenting the client to third-party services may + /// breach their terms of service. + pub fn on_request(mut self, on_request: F) -> Self + where + F: Fn(&RequestContext<'_>, &mut HeaderMap) + Send + Sync + 'static, + { + self.on_request.replace(Box::new(on_request)); + self + } + + /// Validates the options, taking the ones that are not set on the builder from the `config`. + fn into_request_headers( + self, + config: &Config, + ) -> std::result::Result + { + request_headers::RequestHeaders::new( + self.user_agent.as_deref().or(config.user_agent.as_deref()), + self.request_headers + .as_deref() + .unwrap_or(&config.request_headers), + self.on_request, + ) + } + + /// Builds the plugin. + /// + /// The setup fails if the user agent or a request header rule is invalid. + pub fn build(self) -> TauriPlugin> { + PluginBuilder::>::new("http") + .setup(move |app, api| { + let config = api.config().clone().unwrap_or_default(); + + let request_headers = std::sync::Arc::new(self.into_request_headers(&config)?); + #[cfg(feature = "cookies")] - cookies_jar: std::sync::Arc::new(cookies_jar), - }; - - app.manage(state); - - Ok(()) - }) - .on_event(|app, event| { - #[cfg(feature = "cookies")] - if let tauri::RunEvent::Exit = event { - let state = app.state::(); - - match state.cookies_jar.request_save() { - Ok(rx) => { - let _ = rx.recv(); - } - Err(_e) => { + let cookies_jar = { + use std::fs::File; + use std::io::BufReader; + + let cache_dir = app.path().app_cache_dir()?; + std::fs::create_dir_all(&cache_dir)?; + + let path = cache_dir.join(COOKIES_FILENAME); + let file = File::options() + .create(true) + .append(true) + .read(true) + .open(&path)?; + + let reader = BufReader::new(file); + CookieJar::load(path.clone(), reader).unwrap_or_else(|_e| { #[cfg(feature = "tracing")] - tracing::error!("failed to save cookie jar: {_e}"); - } + tracing::warn!( + "failed to load cookie store: {_e}, falling back to empty store" + ); + CookieJar::new(path, Default::default()) + }) + }; + + let state = Http { + config, + request_headers, + #[cfg(feature = "cookies")] + cookies_jar: std::sync::Arc::new(cookies_jar), + }; + + app.manage(state); + + Ok(()) + }) + .on_event(|_app, _event| { + #[cfg(feature = "cookies")] + if let tauri::RunEvent::Exit = _event + && let Err(_e) = _app.http().cookies_jar.save() + { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); } - } - }) - .invoke_handler(tauri::generate_handler![ - commands::fetch, - commands::fetch_cancel, - commands::fetch_send, - commands::fetch_read_body, - commands::fetch_cancel_body, - ]) - .build() + }) + .invoke_handler(tauri::generate_handler![ + commands::fetch, + commands::fetch_cancel, + commands::fetch_send, + commands::fetch_read_body, + commands::fetch_cancel_body, + ]) + .build() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::request_headers::tests::{apply, header}; + + fn config(json: &str) -> Config { + serde_json::from_str(json).unwrap() + } + + #[test] + fn builder_options_take_precedence_over_the_config() { + let config = config( + r#"{ + "userAgent": "config/1.0", + "requestHeaders": [ + { "url": "https://api.example.com", "headers": { "X-Source": "config" } } + ] + }"#, + ); + + // nothing set on the builder: the config applies + let request_headers = Builder::new().into_request_headers(&config).unwrap(); + let headers = apply(&request_headers, "https://api.example.com", &[]); + assert_eq!(header(&headers, "user-agent"), Some("config/1.0")); + assert_eq!(header(&headers, "x-source"), Some("config")); + + // the builder's rules replace the config's list instead of extending it + let request_headers = Builder::new() + .user_agent("builder/1.0") + .request_headers([ + RequestHeaderRule::new("https://other.example.com").header("X-Source", "builder") + ]) + .into_request_headers(&config) + .unwrap(); + let headers = apply(&request_headers, "https://api.example.com", &[]); + assert_eq!(header(&headers, "user-agent"), Some("builder/1.0")); + assert_eq!(header(&headers, "x-source"), None); + let headers = apply(&request_headers, "https://other.example.com", &[]); + assert_eq!(header(&headers, "x-source"), Some("builder")); + + // an empty list on the builder disables the config's rules + let request_headers = Builder::new() + .request_headers([]) + .into_request_headers(&config) + .unwrap(); + let headers = apply(&request_headers, "https://api.example.com", &[]); + assert_eq!(header(&headers, "user-agent"), Some("config/1.0")); + assert_eq!(header(&headers, "x-source"), None); + } + + #[test] + fn invalid_builder_options_are_rejected() { + let config = Config::default(); + assert!( + Builder::new() + .user_agent("line\nbreak") + .into_request_headers(&config) + .is_err() + ); + assert!( + Builder::new() + .request_headers([RequestHeaderRule::new("https://*").header("Cookie", "a=b")]) + .into_request_headers(&config) + .is_err() + ); + + // an invalid config is not used when the builder replaces it + let config: Config = serde_json::from_str( + r#"{ "requestHeaders": [{ "url": "https://*", "headers": { "Host": "x" } }] }"#, + ) + .unwrap(); + assert!(Builder::new().into_request_headers(&config).is_err()); + assert!( + Builder::new() + .request_headers([]) + .into_request_headers(&config) + .is_ok() + ); + } + + #[test] + fn hook_is_kept() { + let request_headers = Builder::new() + .on_request(|_, headers| { + headers.insert("x-hook", reqwest::header::HeaderValue::from_static("1")); + }) + .into_request_headers(&Config::default()) + .unwrap(); + let headers = apply(&request_headers, "https://api.example.com", &[]); + assert_eq!(header(&headers, "x-hook"), Some("1")); + } } diff --git a/plugins/http/src/redirect.rs b/plugins/http/src/redirect.rs new file mode 100644 index 0000000000..f8becbe603 --- /dev/null +++ b/plugins/http/src/redirect.rs @@ -0,0 +1,676 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! Follows redirects for [`fetch`](crate::commands::fetch), computing the headers of every hop. + +use std::collections::HashSet; + +use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header}; + +use crate::{Error, Result, scope::Scope}; + +/// Matches the default [`reqwest`] redirect policy, used when the frontend does not +/// configure `maxRedirections`. +pub(crate) const DEFAULT_MAX_REDIRECTIONS: usize = 10; + +/// How [`send`] follows redirects. +pub(crate) struct Redirects { + /// When [`Some`], **every** hop is checked against the scope. Validating only the URL the + /// frontend asked for is not enough: a server on an allowed origin can answer with a redirect + /// to any other origin (an open redirect, or a server the attacker controls), and following it + /// would give the webview access to a URL the scope denies. + /// + /// That check is opt-in through the `scopeRedirects` configuration because it breaks + /// applications that rely on being redirected outside of their scope. + // TODO(v3): always check the scope and take it by value instead of `Option` + pub(crate) scope: Option, + /// How many redirects are followed before failing. `0` returns the redirect response instead. + pub(crate) max: usize, + /// Whether a redirected request carries the previous URL as `Referer`, as + /// [`reqwest::ClientBuilder::referer`] does. The client does not follow redirects, so its own + /// setting has no effect. + pub(crate) referer: bool, +} + +/// Sends `request`, following redirects the way [`reqwest`]'s default policy does, except that the +/// plugin follows them itself so the headers of every hop are computed for its URL. +/// +/// `headers` holds the frontend's headers and the ones derived from the request; `add_headers` +/// adds the user agent, origin, request header rules and `on_request` hook for a hop, given its URL, +/// method and the names of the frontend headers it still carries. That way nothing added for one +/// URL is sent to a redirect target it was not meant for. The client must not follow redirects. +/// +/// The headers `request` already has, such as the `Authorization` [`reqwest`] derives from the +/// credentials of the URL, count as the frontend's unless `headers` sets them too. +pub(crate) async fn send( + client: reqwest::Client, + mut request: reqwest::Request, + mut headers: HeaderMap, + mut frontend_headers: HashSet, + redirects: Redirects, + add_headers: F, +) -> Result +where + F: Fn(&url::Url, &Method, &HashSet, &mut HeaderMap) -> Result<()>, +{ + let request_headers = std::mem::take(request.headers_mut()); + for name in request_headers.keys() { + if !headers.contains_key(name) { + for value in request_headers.get_all(name) { + headers.append(name.clone(), value.clone()); + } + frontend_headers.insert(name.clone()); + } + } + + let mut redirections = 0; + let mut referer = None; + + loop { + // kept to send the request again if the response is a redirect; the body is in memory + let next = request.try_clone(); + + let mut hop_headers = headers.clone(); + if let Some(referer) = referer.take() { + hop_headers.insert(header::REFERER, referer); + } + add_headers( + request.url(), + request.method(), + &frontend_headers, + &mut hop_headers, + )?; + *request.headers_mut() = hop_headers; + + #[cfg(feature = "tracing")] + tracing::trace!("{:?}", request); + + let response = client.execute(request).await?; + + let Some(location) = redirect_location(&response) else { + return Ok(response); + }; + let Some(mut next) = next.filter(|_| redirects.max > 0) else { + return Ok(response); + }; + + redirections += 1; + if redirections > redirects.max { + return Err(Error::TooManyRedirects(location)); + } + if let Some(scope) = &redirects.scope + && !scope.is_allowed(&location) + { + return Err(Error::UrlNotAllowed(location)); + } + + // https://fetch.spec.whatwg.org/#http-redirect-fetch step 12 + let drop_payload = match response.status() { + StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => next.method() == Method::POST, + StatusCode::SEE_OTHER => true, + _ => false, + }; + if drop_payload { + if next.method() != Method::HEAD { + *next.method_mut() = Method::GET; + } + *next.body_mut() = None; + for name in [ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::CONTENT_ENCODING, + header::CONTENT_LANGUAGE, + header::CONTENT_LOCATION, + header::TRANSFER_ENCODING, + ] { + headers.remove(&name); + frontend_headers.remove(&name); + } + } + + // the credentials were meant for the origin the frontend requested: once the chain leaves + // it, they are not sent again + if next.url().origin() != location.origin() { + for name in [ + header::AUTHORIZATION, + header::COOKIE, + HeaderName::from_static("cookie2"), + header::PROXY_AUTHORIZATION, + header::WWW_AUTHENTICATE, + ] { + headers.remove(&name); + frontend_headers.remove(&name); + } + } + + if redirects.referer { + referer = make_referer(&location, next.url()); + } + *next.url_mut() = location; + request = next; + } +} + +/// The URL a response redirects to, if it is a redirect the plugin follows. +fn redirect_location(response: &reqwest::Response) -> Option { + if !matches!( + response.status(), + StatusCode::MOVED_PERMANENTLY + | StatusCode::FOUND + | StatusCode::SEE_OTHER + | StatusCode::TEMPORARY_REDIRECT + | StatusCode::PERMANENT_REDIRECT + ) { + return None; + } + let location = response.headers().get(header::LOCATION)?; + let location = std::str::from_utf8(location.as_bytes()).ok()?; + response.url().join(location).ok() +} + +/// The `Referer` reqwest sends on redirects: the previous URL without credentials and fragment, +/// and nothing when going from `https` to `http`. +fn make_referer(next: &url::Url, previous: &url::Url) -> Option { + if next.scheme() == "http" && previous.scheme() == "https" { + return None; + } + + let mut referer = previous.clone(); + let _ = referer.set_username(""); + let _ = referer.set_password(None); + referer.set_fragment(None); + referer.as_str().parse().ok() +} + +#[cfg(test)] +mod tests { + use std::{ + io::{BufRead, BufReader, Read, Write}, + net::TcpListener, + sync::Arc, + }; + + use reqwest::redirect::Policy; + + use super::*; + use crate::{RequestContext, RequestHeaderRule, request_headers::RequestHeaders}; + + /// Test server with an open redirect: + /// + /// - `/redirect-external` answers a 302 to `http://127.0.0.1:{port}/secret`, which is a + /// different origin as far as the scope is concerned - the tests allow `localhost`; + /// - `/redirect/{n}` answers a 302 to `/redirect/{n-1}` on `localhost`, and `/redirect/0` + /// answers a 302 to `/target`, so the whole chain stays in scope; + /// - `/r/{status}/{host}/{path}` answers a `{status}` redirect to `http://{host}:{port}/{path}`, + /// or to the relative `/{path}` when `{host}` is `-`; + /// - anything else answers a 200 with the method, path, headers and body it received as JSON, + /// so a bypass shows up as a successful response. + fn spawn_server() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = stream.unwrap(); + + // the whole request must be consumed, otherwise closing the connection + // with unread data queued resets it before the client reads the response + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + let mut headers = serde_json::Map::new(); + loop { + let mut header = String::new(); + reader.read_line(&mut header).unwrap(); + if matches!(header.as_str(), "" | "\r\n" | "\n") { + break; + } + if let Some((name, value)) = header.split_once(':') { + headers.insert(name.trim().to_ascii_lowercase(), value.trim().into()); + } + } + let length = headers + .get("content-length") + .and_then(|value| value.as_str()?.parse().ok()) + .unwrap_or(0); + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + + let mut parts = request_line.split(' '); + let method = parts.next().unwrap_or_default(); + let path = parts.next().unwrap_or("/"); + let redirect = match path.strip_prefix("/redirect/") { + Some("0") => Some(("302", format!("http://localhost:{port}/target"))), + Some(remaining) => Some(( + "302", + format!( + "http://localhost:{port}/redirect/{}", + remaining.parse::().unwrap() - 1 + ), + )), + None if path == "/redirect-external" => { + Some(("302", format!("http://127.0.0.1:{port}/secret"))) + } + None => path.strip_prefix("/r/").map(|rest| { + let mut parts = rest.splitn(3, '/'); + let status = parts.next().unwrap(); + let host = parts.next().unwrap(); + let path = parts.next().unwrap_or_default(); + let location = if host == "-" { + format!("/{path}") + } else { + format!("http://{host}:{port}/{path}") + }; + (status, location) + }), + }; + + let response = match redirect { + Some((status, location)) => format!( + "HTTP/1.1 {status} Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ), + None => { + let echo = serde_json::json!({ + "method": method, + "path": path, + "headers": headers, + "body": String::from_utf8_lossy(&body), + }) + .to_string(); + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{echo}", + echo.len() + ) + } + }; + + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + port + } + + /// Sends a request the way `fetch` does, with the given frontend headers and header rules. + fn fetch( + method: Method, + url: &str, + body: Option<&str>, + frontend: &[(&str, &str)], + redirects: Redirects, + rules: &[RequestHeaderRule], + ) -> Result { + let client = reqwest::ClientBuilder::new() + .redirect(Policy::none()) + .build() + .unwrap(); + + let mut headers = HeaderMap::new(); + for (name, value) in frontend { + headers.append( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + let frontend_headers = headers.keys().cloned().collect(); + + let mut request = client.request(method, url); + if let Some(body) = body { + request = request.body(body.to_string()); + } + let request = request.build().unwrap(); + + let request_headers = RequestHeaders::new(None, rules, None).unwrap(); + let add_headers = move |url: &url::Url, + method: &Method, + frontend_headers: &HashSet, + headers: &mut HeaderMap| { + let ctx = RequestContext::new( + url, + method, + "main", + Some("tauri://localhost"), + frontend_headers, + ); + request_headers.apply(&ctx, headers) + }; + + tauri::async_runtime::block_on(send( + client, + request, + headers, + frontend_headers, + redirects, + add_headers, + )) + } + + fn get( + url: &str, + scope: Option, + max_redirections: Option, + ) -> Result { + let redirects = Redirects { + scope, + max: max_redirections.unwrap_or(DEFAULT_MAX_REDIRECTIONS), + referer: true, + }; + fetch(Method::GET, url, None, &[], redirects, &[]) + } + + /// Follows redirects without a scope, up to the default limit. + fn follow() -> Redirects { + Redirects { + scope: None, + max: DEFAULT_MAX_REDIRECTIONS, + referer: true, + } + } + + /// What the server received for the last request of the chain. + fn echo(response: Result) -> serde_json::Value { + let response = response.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let text = tauri::async_runtime::block_on(response.text()).unwrap(); + serde_json::from_str(&text).unwrap() + } + + fn received<'a>(echo: &'a serde_json::Value, header: &str) -> Option<&'a str> { + echo["headers"][header].as_str() + } + + fn localhost_scope(port: u16) -> Option { + let entry = Arc::new(format!("http://localhost:{port}/*").parse().unwrap()); + Some(Scope::new(vec![entry], Vec::new())) + } + + #[test] + fn redirect_outside_of_scope_is_denied() { + let port = spawn_server(); + + let err = get( + &format!("http://localhost:{port}/redirect-external"), + localhost_scope(port), + None, + ) + .unwrap_err(); + + match err { + Error::UrlNotAllowed(url) => { + assert_eq!(url.as_str(), format!("http://127.0.0.1:{port}/secret")) + } + e => panic!("expected the redirect to be denied by the scope, got {e:?}"), + } + } + + #[test] + fn redirect_outside_of_scope_is_followed_when_not_configured() { + let port = spawn_server(); + + // `scopeRedirects` is disabled, so only the URL requested by the frontend is checked + let response = get( + &format!("http://localhost:{port}/redirect-external"), + None, + None, + ) + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn redirect_denied_by_the_scope_is_denied() { + let port = spawn_server(); + let allow = Arc::new(format!("http://localhost:{port}/*").parse().unwrap()); + let deny = Arc::new(format!("http://localhost:{port}/target").parse().unwrap()); + let scope = Some(Scope::new(vec![allow], vec![deny])); + + let err = get(&format!("http://localhost:{port}/redirect/0"), scope, None).unwrap_err(); + + assert!(matches!(err, Error::UrlNotAllowed(_))); + } + + #[test] + fn redirect_inside_of_scope_is_followed() { + let port = spawn_server(); + + let response = get( + &format!("http://localhost:{port}/redirect/2"), + localhost_scope(port), + None, + ) + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.url().path(), "/target"); + } + + #[test] + fn max_redirections_is_enforced() { + let port = spawn_server(); + + for scope in [localhost_scope(port), None] { + let err = get( + &format!("http://localhost:{port}/redirect/5"), + scope, + Some(2), + ) + .unwrap_err(); + + match err { + Error::TooManyRedirects(url) => { + assert_eq!(url.as_str(), format!("http://localhost:{port}/redirect/2")) + } + e => panic!("expected too many redirects, got {e:?}"), + } + } + } + + #[test] + fn zero_max_redirections_does_not_follow() { + let port = spawn_server(); + + let response = get( + &format!("http://localhost:{port}/redirect/0"), + localhost_scope(port), + Some(0), + ) + .unwrap(); + + assert_eq!(response.status(), StatusCode::FOUND); + } + + #[test] + fn rule_headers_are_applied_again_on_every_hop() { + let port = spawn_server(); + let localhost_rule = RequestHeaderRule::new(format!("http://localhost:{port}/*")) + .header("X-Api-Key", "for-localhost") + .header("Origin", "https://desktop.example.com"); + + // a redirect within the rule's URLs keeps its headers + let received_echo = echo(fetch( + Method::GET, + &format!("http://localhost:{port}/r/302/-/echo"), + None, + &[], + follow(), + std::slice::from_ref(&localhost_rule), + )); + assert_eq!(received(&received_echo, "x-api-key"), Some("for-localhost")); + assert_eq!( + received(&received_echo, "origin"), + Some("https://desktop.example.com") + ); + + // a redirect target the rule does not match gets the plugin defaults instead + let received_echo = echo(fetch( + Method::GET, + &format!("http://localhost:{port}/r/302/127.0.0.1/echo"), + None, + &[], + follow(), + std::slice::from_ref(&localhost_rule), + )); + assert_eq!(received(&received_echo, "x-api-key"), None); + assert_eq!( + received(&received_echo, "origin"), + Some("tauri://localhost") + ); + + // and the rules matching the redirect target apply to it + let rules = [ + localhost_rule, + RequestHeaderRule::new(format!("http://127.0.0.1:{port}/*")) + .header("X-Api-Key", "for-127.0.0.1"), + ]; + let received_echo = echo(fetch( + Method::GET, + &format!("http://localhost:{port}/r/307/127.0.0.1/echo"), + None, + &[], + follow(), + &rules, + )); + assert_eq!(received(&received_echo, "x-api-key"), Some("for-127.0.0.1")); + assert_eq!( + received(&received_echo, "origin"), + Some("tauri://localhost") + ); + } + + #[test] + fn credentials_are_not_sent_to_another_origin_for_the_rest_of_the_chain() { + let port = spawn_server(); + let frontend = [("Authorization", "Bearer for-localhost")]; + + let received_echo = echo(fetch( + Method::GET, + &format!("http://localhost:{port}/r/302/-/echo"), + None, + &frontend, + follow(), + &[], + )); + assert_eq!( + received(&received_echo, "authorization"), + Some("Bearer for-localhost") + ); + + // still not sent after a second, same-origin hop + let received_echo = echo(fetch( + Method::GET, + &format!("http://localhost:{port}/r/302/127.0.0.1/r/302/-/echo"), + None, + &frontend, + follow(), + &[], + )); + assert_eq!(received_echo["path"], "/echo"); + assert_eq!(received(&received_echo, "authorization"), None); + + // the frontend's header is gone, so a rule's default for the new origin applies + let rules = [RequestHeaderRule::new(format!("http://127.0.0.1:{port}/*")) + .header("Authorization", "Bearer for-127.0.0.1")]; + let received_echo = echo(fetch( + Method::GET, + &format!("http://localhost:{port}/r/302/127.0.0.1/echo"), + None, + &frontend, + follow(), + &rules, + )); + assert_eq!( + received(&received_echo, "authorization"), + Some("Bearer for-127.0.0.1") + ); + } + + #[test] + fn redirects_change_the_method_and_body_like_fetch() { + let port = spawn_server(); + let frontend = [("Content-Type", "text/plain")]; + let send = |method: Method, status: u16| { + echo(fetch( + method, + &format!("http://localhost:{port}/r/{status}/-/echo"), + Some("payload"), + &frontend, + follow(), + &[], + )) + }; + + for (method, status) in [(Method::POST, 301), (Method::POST, 302), (Method::PUT, 303)] { + let received_echo = send(method.clone(), status); + assert_eq!(received_echo["method"], "GET", "{method} {status}"); + assert_eq!(received_echo["body"], "", "{method} {status}"); + assert_eq!( + received(&received_echo, "content-type"), + None, + "{method} {status}" + ); + } + + for (method, status) in [(Method::PUT, 302), (Method::POST, 307), (Method::PUT, 308)] { + let received_echo = send(method.clone(), status); + assert_eq!( + received_echo["method"], + method.as_str(), + "{method} {status}" + ); + assert_eq!(received_echo["body"], "payload", "{method} {status}"); + assert_eq!( + received(&received_echo, "content-type"), + Some("text/plain"), + "{method} {status}" + ); + } + } + + #[test] + fn redirects_send_the_previous_url_as_referer() { + let port = spawn_server(); + + let received_echo = echo(get( + &format!("http://user:pass@localhost:{port}/r/302/-/echo"), + None, + None, + )); + assert_eq!( + received(&received_echo, "referer"), + Some(format!("http://localhost:{port}/r/302/-/echo").as_str()) + ); + + // the credentials of the URL are still sent, as basic auth + let received_echo = echo(get( + &format!("http://user:pass@localhost:{port}/echo"), + None, + None, + )); + assert_eq!( + received(&received_echo, "authorization"), + Some("Basic dXNlcjpwYXNz") + ); + + // unless disabled + let received_echo = echo(fetch( + Method::GET, + &format!("http://localhost:{port}/r/302/-/echo"), + None, + &[], + Redirects { + referer: false, + ..follow() + }, + &[], + )); + assert_eq!(received(&received_echo, "referer"), None); + + let https: url::Url = "https://example.com/a".parse().unwrap(); + let http: url::Url = "http://example.com/b".parse().unwrap(); + assert!(make_referer(&http, &https).is_none()); + assert!(make_referer(&https, &http).is_some()); + } +} diff --git a/plugins/http/src/request_headers.rs b/plugins/http/src/request_headers.rs new file mode 100644 index 0000000000..eff57cfafc --- /dev/null +++ b/plugins/http/src/request_headers.rs @@ -0,0 +1,635 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! The headers the plugin adds to webview requests: the default `User-Agent` and `Origin`, the +//! [`RequestHeaderRule`]s and the [`Builder::on_request`](crate::Builder::on_request) hook. + +use std::{ + collections::{BTreeMap, HashSet}, + fmt, +}; + +use http::{HeaderMap, HeaderName, HeaderValue, Method, header}; +use serde::{Deserialize, Deserializer}; +use url::Url; +use urlpattern::{UrlPattern, UrlPatternMatchInput}; + +use crate::scope::parse_url_pattern; + +const DEFAULT_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + +/// Headers that rules cannot set or remove: they describe the message framing or the target, or, +/// for `Cookie`, come from the cookie jar. +const DENIED_HEADERS: &[HeaderName] = &[ + header::HOST, + header::CONTENT_LENGTH, + header::TRANSFER_ENCODING, + header::COOKIE, +]; + +pub(crate) type OnRequest = Box, &mut HeaderMap) + Send + Sync>; + +/// When a [`RequestHeaderRule`] applies its headers. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum HeaderRuleMode { + /// Applies a header only if the frontend did not set it. This replaces the `User-Agent` and + /// `Origin` the plugin derives, but keeps the values the frontend sets explicitly. + #[default] + Default, + /// Applies the headers unconditionally. + Override, +} + +/// Sets or removes request headers on webview requests to the URLs matching a pattern. +/// +/// Rules are configured on the `requestHeaders` array of the [`Config`](crate::Config), or with +/// [`Builder::request_headers`](crate::Builder::request_headers). Every rule matching the request +/// URL applies, in order, so a later rule overrides an earlier one for the same header. +/// +/// The `url` pattern uses the same syntax as the HTTP scope entries. It only decides which requests +/// get the headers: requests must still be allowed by the scope. +/// +/// The rules apply again to every redirect the plugin follows, matched against the redirect +/// target, so the headers are never sent to a URL the rule does not match. +/// +/// Rules can set headers the frontend is not allowed to send, such as `Origin`, but not `Host`, +/// `Content-Length`, `Transfer-Encoding` and `Cookie`. Their values are treated as sensitive: they +/// are left out of the `Debug` output of the rule and of the requests. +/// +/// ```json +/// { +/// "url": "https://api.example.com/*", +/// "headers": { "Origin": "https://desktop.example.com", "X-Debug": null }, +/// "mode": "override" +/// } +/// ``` +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RequestHeaderRule { + url: String, + #[serde(deserialize_with = "deserialize_headers")] + headers: Vec<(String, Option)>, + #[serde(default)] + mode: HeaderRuleMode, +} + +fn deserialize_headers<'de, D>(deserializer: D) -> Result)>, D::Error> +where + D: Deserializer<'de>, +{ + BTreeMap::>::deserialize(deserializer) + .map(|map| map.into_iter().collect()) +} + +impl fmt::Debug for RequestHeaderRule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // the values may be secrets, such as API keys + let headers = self + .headers + .iter() + .map(|(name, value)| (name, value.as_ref().map(|_| "Sensitive"))) + .collect::>(); + f.debug_struct("RequestHeaderRule") + .field("url", &self.url) + .field("headers", &headers) + .field("mode", &self.mode) + .finish() + } +} + +impl RequestHeaderRule { + /// Creates a rule for the URLs matching the given pattern, with no headers and the + /// [`HeaderRuleMode::Default`] mode. + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + headers: Vec::new(), + mode: HeaderRuleMode::Default, + } + } + + /// Sets the given header. + pub fn header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.push((name.into(), Some(value.into()))); + self + } + + /// Removes the given header. + pub fn remove_header(mut self, name: impl Into) -> Self { + self.headers.push((name.into(), None)); + self + } + + /// Sets when the rule applies its headers. + pub fn mode(mut self, mode: HeaderRuleMode) -> Self { + self.mode = mode; + self + } +} + +/// The request a [`Builder::on_request`](crate::Builder::on_request) hook is called for. +#[derive(Debug)] +pub struct RequestContext<'a> { + url: &'a Url, + method: &'a Method, + webview_label: &'a str, + webview_origin: Option<&'a str>, + frontend_headers: &'a HashSet, +} + +impl<'a> RequestContext<'a> { + pub(crate) fn new( + url: &'a Url, + method: &'a Method, + webview_label: &'a str, + webview_origin: Option<&'a str>, + frontend_headers: &'a HashSet, + ) -> Self { + Self { + url, + method, + webview_label, + webview_origin, + frontend_headers, + } + } + + /// The URL of the request: the one requested by the frontend, or the target of a redirect the + /// plugin is following. + pub fn url(&self) -> &Url { + self.url + } + + /// The request method, which a redirect can change to `GET`. + pub fn method(&self) -> &Method { + self.method + } + + /// The label of the webview that sent the request. + pub fn webview_label(&self) -> &str { + self.webview_label + } + + /// The origin of the webview that sent the request, which the plugin sends as `Origin` by + /// default: `tauri://localhost` or `http://tauri.localhost` in production, depending on the + /// platform, and the dev server URL during development. + pub fn webview_origin(&self) -> Option<&str> { + self.webview_origin + } + + /// Whether the frontend set the given header. + /// + /// Headers the frontend is not allowed to send, such as `Origin` without the `unsafe-headers` + /// feature, are dropped before the hook runs, so they are never reported as set. Neither are + /// the frontend headers a redirect dropped: `Authorization` on a redirect to another origin, + /// and `Content-Type` when a redirect turns the request into a `GET`. + pub fn frontend_set(&self, name: &HeaderName) -> bool { + self.frontend_headers.contains(name) + } +} + +/// An invalid `userAgent` or `requestHeaders` value, reported when the plugin is set up. +#[derive(Debug)] +pub(crate) struct InvalidRequestHeaders(String); + +impl fmt::Display for InvalidRequestHeaders { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid HTTP plugin configuration: {}", self.0) + } +} + +impl std::error::Error for InvalidRequestHeaders {} + +struct CompiledRule { + url: UrlPattern, + headers: Vec<(HeaderName, Option)>, + mode: HeaderRuleMode, +} + +impl CompiledRule { + fn new(index: usize, rule: &RequestHeaderRule) -> Result { + let error = |reason: String| { + InvalidRequestHeaders(format!( + "request header rule #{index} (`{}`): {reason}", + rule.url + )) + }; + + let url = parse_url_pattern(&rule.url) + .map_err(|e| error(format!("`{}` is not a valid URL pattern: {e}", rule.url)))?; + + let mut headers: Vec<(HeaderName, Option)> = Vec::new(); + for (name, value) in &rule.headers { + let name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|e| error(format!("`{name}` is not a valid header name: {e}")))?; + if DENIED_HEADERS.contains(&name) { + return Err(error(format!( + "the `{name}` header cannot be set by a rule" + ))); + } + if headers.iter().any(|(n, _)| *n == name) { + return Err(error(format!( + "the `{name}` header is listed more than once" + ))); + } + let value = value + .as_deref() + .map(HeaderValue::from_str) + .transpose() + .map_err(|e| error(format!("invalid value for the `{name}` header: {e}")))? + .map(|mut value| { + // keeps secrets such as API keys out of the request's `Debug` output + value.set_sensitive(true); + value + }); + headers.push((name, value)); + } + + Ok(Self { + url, + headers, + mode: rule.mode, + }) + } + + fn matches(&self, url: &Url) -> bool { + self.url + .test(UrlPatternMatchInput::Url(url.clone())) + .unwrap_or_default() + } +} + +/// The validated `userAgent`, `requestHeaders` and `on_request` hook. +pub(crate) struct RequestHeaders { + user_agent: HeaderValue, + rules: Vec, + on_request: Option, +} + +impl RequestHeaders { + pub(crate) fn new( + user_agent: Option<&str>, + rules: &[RequestHeaderRule], + on_request: Option, + ) -> Result { + let user_agent = HeaderValue::from_str(user_agent.unwrap_or(DEFAULT_USER_AGENT)) + .map_err(|e| InvalidRequestHeaders(format!("invalid user agent: {e}")))?; + let rules = rules + .iter() + .enumerate() + .map(|(index, rule)| CompiledRule::new(index, rule)) + .collect::>()?; + + Ok(Self { + user_agent, + rules, + on_request, + }) + } + + /// Adds the plugin's default `User-Agent` and `Origin`, then applies the matching rules, then + /// runs the hook. + pub(crate) fn apply( + &self, + ctx: &RequestContext<'_>, + headers: &mut HeaderMap, + ) -> crate::Result<()> { + if !headers.contains_key(header::USER_AGENT) { + headers.insert(header::USER_AGENT, self.user_agent.clone()); + } + + // ensure we have an Origin header set + if (cfg!(not(feature = "unsafe-headers")) || !headers.contains_key(header::ORIGIN)) + && let Some(origin) = ctx.webview_origin + { + headers.insert(header::ORIGIN, HeaderValue::from_str(origin)?); + } + + // In case empty origin is passed, remove it. Some services do not like Origin header + // so this way we can remove it in explicit way. The default behaviour is still to set it + if cfg!(feature = "unsafe-headers") + && headers.get(header::ORIGIN) == Some(&HeaderValue::from_static("")) + { + headers.remove(header::ORIGIN); + }; + + for rule in self.rules.iter().filter(|rule| rule.matches(ctx.url)) { + for (name, value) in &rule.headers { + if rule.mode == HeaderRuleMode::Default && ctx.frontend_set(name) { + continue; + } + match value { + Some(value) => headers.insert(name.clone(), value.clone()), + None => headers.remove(name), + }; + } + } + + if let Some(on_request) = &self.on_request { + on_request(ctx, headers); + } + + Ok(()) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + const WEBVIEW_ORIGIN: &str = "tauri://localhost"; + + fn rules(json: &str) -> Vec { + serde_json::from_str(json).unwrap() + } + + /// Applies the headers to a GET request from the frontend. + pub(crate) fn apply( + policy: &RequestHeaders, + url: &str, + frontend: &[(&str, &str)], + ) -> HeaderMap { + let url: Url = url.parse().unwrap(); + let mut headers = HeaderMap::new(); + for (name, value) in frontend { + headers.insert( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + let frontend_headers = headers.keys().cloned().collect(); + let ctx = RequestContext::new( + &url, + &Method::GET, + "main", + Some(WEBVIEW_ORIGIN), + &frontend_headers, + ); + policy.apply(&ctx, &mut headers).unwrap(); + headers + } + + pub(crate) fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).map(|v| v.to_str().unwrap()) + } + + #[test] + fn plugin_defaults() { + let policy = RequestHeaders::new(None, &[], None).unwrap(); + let headers = apply(&policy, "https://api.example.com", &[]); + assert_eq!(header(&headers, "user-agent"), Some(DEFAULT_USER_AGENT)); + assert_eq!(header(&headers, "origin"), Some(WEBVIEW_ORIGIN)); + + let policy = RequestHeaders::new(Some("my-app/1.0"), &[], None).unwrap(); + let headers = apply(&policy, "https://api.example.com", &[]); + assert_eq!(header(&headers, "user-agent"), Some("my-app/1.0")); + + // the frontend's User-Agent takes precedence over the default + let headers = apply( + &policy, + "https://api.example.com", + &[("User-Agent", "web/1.0")], + ); + assert_eq!(header(&headers, "user-agent"), Some("web/1.0")); + } + + #[test] + fn rules_apply_in_order() { + let policy = RequestHeaders::new( + None, + &rules( + r#"[ + { "url": "https://*.example.com/*", "headers": { "Origin": "https://a.example.com", "X-A": "a" } }, + { "url": "https://api.example.com/*", "headers": { "Origin": "https://b.example.com", "User-Agent": null } }, + { "url": "https://other.test/*", "headers": { "X-Other": "1" } } + ]"#, + ), + None, + ) + .unwrap(); + + let headers = apply(&policy, "https://api.example.com/v1/login", &[]); + assert_eq!(header(&headers, "origin"), Some("https://b.example.com")); + assert_eq!(header(&headers, "x-a"), Some("a")); + assert_eq!(header(&headers, "user-agent"), None); + assert_eq!(header(&headers, "x-other"), None); + + // only the first rule matches + let headers = apply(&policy, "https://cdn.example.com/image.png", &[]); + assert_eq!(header(&headers, "origin"), Some("https://a.example.com")); + assert_eq!(header(&headers, "user-agent"), Some(DEFAULT_USER_AGENT)); + + // no rule matches, so the plugin defaults apply + let headers = apply(&policy, "https://unknown.test", &[]); + assert_eq!(header(&headers, "origin"), Some(WEBVIEW_ORIGIN)); + assert_eq!(header(&headers, "x-a"), None); + } + + #[test] + fn default_mode_keeps_frontend_headers() { + let policy = RequestHeaders::new( + None, + &rules( + r#"[ + { "url": "https://api.example.com", "headers": { "User-Agent": "rule/1.0", "X-Client": "rule" } }, + { "url": "https://override.example.com", "headers": { "User-Agent": "rule/1.0", "X-Client": null }, "mode": "override" } + ]"#, + ), + None, + ) + .unwrap(); + + let frontend = [("User-Agent", "web/1.0"), ("X-Client", "web")]; + + let headers = apply(&policy, "https://api.example.com", &frontend); + assert_eq!(header(&headers, "user-agent"), Some("web/1.0")); + assert_eq!(header(&headers, "x-client"), Some("web")); + + // the rule still replaces the plugin's default + let headers = apply(&policy, "https://api.example.com", &[]); + assert_eq!(header(&headers, "user-agent"), Some("rule/1.0")); + + let headers = apply(&policy, "https://override.example.com", &frontend); + assert_eq!(header(&headers, "user-agent"), Some("rule/1.0")); + assert_eq!(header(&headers, "x-client"), None); + } + + #[test] + fn hook_runs_last() { + let policy = RequestHeaders::new( + Some("my-app/1.0"), + &rules( + r#"[{ "url": "https://*", "headers": { "Origin": "https://rule.example.com" } }]"#, + ), + Some(Box::new(|ctx, headers| { + assert_eq!(ctx.webview_label(), "main"); + assert_eq!(ctx.webview_origin(), Some(WEBVIEW_ORIGIN)); + assert_eq!(*ctx.method(), Method::GET); + assert!(ctx.frontend_set(&header::ACCEPT)); + assert!(!ctx.frontend_set(&header::USER_AGENT)); + + if ctx.url().host_str() == Some("api.example.com") { + headers.insert( + header::ORIGIN, + HeaderValue::from_static("https://hook.example.com"), + ); + headers.remove(header::USER_AGENT); + } + })), + ) + .unwrap(); + + let headers = apply(&policy, "https://api.example.com", &[("Accept", "*/*")]); + assert_eq!(header(&headers, "origin"), Some("https://hook.example.com")); + assert_eq!(header(&headers, "user-agent"), None); + + let headers = apply(&policy, "https://cdn.example.com", &[("Accept", "*/*")]); + assert_eq!(header(&headers, "origin"), Some("https://rule.example.com")); + assert_eq!(header(&headers, "user-agent"), Some("my-app/1.0")); + } + + #[test] + fn invalid_configuration_is_rejected() { + let invalid = [ + r#"[{ "url": "https://*", "headers": { "Cookie": "a=b" } }]"#, + r#"[{ "url": "https://*", "headers": { "host": null } }]"#, + r#"[{ "url": "https://*", "headers": { "Content-Length": "0" } }]"#, + r#"[{ "url": "https://*", "headers": { "Transfer-Encoding": "chunked" } }]"#, + r#"[{ "url": "https://*", "headers": { "Bad Name": "x" } }]"#, + r#"[{ "url": "https://*", "headers": { "X-Value": "line\nbreak" } }]"#, + r#"[{ "url": "https://*", "headers": { "Origin": "a", "origin": "b" } }]"#, + r#"[{ "url": "https://[", "headers": {} }]"#, + ]; + for json in invalid { + assert!( + RequestHeaders::new(None, &rules(json), None).is_err(), + "{json} should be rejected" + ); + } + + assert!(RequestHeaders::new(Some("line\nbreak"), &[], None).is_err()); + + // unknown fields and modes fail to deserialize + assert!( + serde_json::from_str::>( + r#"[{ "url": "https://*", "header": {} }]"# + ) + .is_err() + ); + assert!( + serde_json::from_str::>( + r#"[{ "url": "https://*", "headers": {}, "mode": "replace" }]"# + ) + .is_err() + ); + } + + #[test] + fn null_removes_plugin_defaults_but_not_frontend_headers() { + let policy = RequestHeaders::new( + None, + &rules( + r#"[{ "url": "https://api.example.com", "headers": { "Origin": null, "User-Agent": null, "X-Client": null } }]"#, + ), + None, + ) + .unwrap(); + + let headers = apply(&policy, "https://api.example.com", &[]); + assert_eq!(header(&headers, "origin"), None); + assert_eq!(header(&headers, "user-agent"), None); + + let headers = apply( + &policy, + "https://api.example.com", + &[("User-Agent", "web/1.0"), ("X-Client", "web")], + ); + assert_eq!(header(&headers, "user-agent"), Some("web/1.0")); + assert_eq!(header(&headers, "x-client"), Some("web")); + } + + #[test] + fn rules_match_like_scope_entries() { + let policy = RequestHeaders::new( + None, + &rules( + r#"[ + { "url": "http://localhost:*", "headers": { "X-Local": "1" } }, + { "url": "https://api.example.com/v1/*", "headers": { "X-V1": "1" } } + ]"#, + ), + None, + ) + .unwrap(); + + for url in ["http://localhost:5173", "http://localhost:8080/path?q=1"] { + assert_eq!( + header(&apply(&policy, url, &[]), "x-local"), + Some("1"), + "{url}" + ); + } + for url in ["https://localhost:5173", "http://127.0.0.1:5173"] { + assert_eq!(header(&apply(&policy, url, &[]), "x-local"), None, "{url}"); + } + + assert_eq!( + header( + &apply(&policy, "https://api.example.com/v1/users?id=1", &[]), + "x-v1" + ), + Some("1") + ); + assert_eq!( + header( + &apply(&policy, "https://api.example.com/v2/users", &[]), + "x-v1" + ), + None + ); + assert_eq!( + header( + &apply(&policy, "https://api.example.com.evil.test/v1/users", &[]), + "x-v1" + ), + None + ); + } + + #[test] + fn rule_values_are_sensitive() { + let rule = RequestHeaderRule::new("https://api.example.com/*") + .header("X-Api-Key", "s3cret") + .remove_header("X-Debug"); + let debug = format!("{rule:?}"); + assert!(debug.contains("X-Api-Key"), "{debug}"); + assert!(!debug.contains("s3cret"), "{debug}"); + + let policy = RequestHeaders::new(None, &[rule], None).unwrap(); + let headers = apply(&policy, "https://api.example.com/v1", &[]); + assert!(headers.get("x-api-key").unwrap().is_sensitive()); + assert!(!format!("{headers:?}").contains("s3cret")); + } + + #[test] + fn rules_built_in_rust() { + let rule = RequestHeaderRule::new("https://api.example.com/*") + .header("Origin", "https://desktop.example.com") + .remove_header("User-Agent") + .mode(HeaderRuleMode::Override); + let policy = RequestHeaders::new(None, &[rule], None).unwrap(); + + let headers = apply( + &policy, + "https://api.example.com/v1", + &[("User-Agent", "web/1.0")], + ); + assert_eq!( + header(&headers, "origin"), + Some("https://desktop.example.com") + ); + assert_eq!(header(&headers, "user-agent"), None); + } +} diff --git a/plugins/http/src/reqwest_cookie_store.rs b/plugins/http/src/reqwest_cookie_store.rs index ef389807bc..021676f1bb 100644 --- a/plugins/http/src/reqwest_cookie_store.rs +++ b/plugins/http/src/reqwest_cookie_store.rs @@ -6,7 +6,10 @@ use std::{ path::PathBuf, - sync::{Mutex, mpsc::Receiver}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, }; use cookie_store::{CookieStore, RawCookie, RawCookieParseError}; @@ -41,80 +44,169 @@ fn cookies(cookie_store: &CookieStore, url: &url::Url) -> Option { HeaderValue::from_maybe_shared(bytes::Bytes::from(s)).ok() } -/// A [`cookie_store::CookieStore`] wrapped internally by a [`std::sync::Mutex`], suitable for use in -/// async/concurrent contexts. -#[derive(Debug)] -pub struct CookieStoreMutex { - pub path: PathBuf, +/// The file the cookie jar is persisted to. +/// +/// Every snapshot of the jar gets an increasing generation, and a snapshot is only written if no +/// newer one has been written yet, so concurrent saves cannot replace the file with stale cookies. +struct CookieFile { + path: PathBuf, + /// The generation of the latest snapshot. + latest: AtomicU64, + /// The generation of the snapshot on disk. + written: Mutex, +} + +impl CookieFile { + /// Writes the snapshot of the given generation. + /// + /// With `skip_if_superseded`, the write is also skipped when a newer snapshot exists, because + /// whoever took that snapshot writes it. + fn write( + &self, + generation: u64, + contents: &str, + skip_if_superseded: bool, + ) -> std::io::Result<()> { + let mut written = self.written.lock().unwrap(); + if *written >= generation + || (skip_if_superseded && self.latest.load(Ordering::SeqCst) > generation) + { + return Ok(()); + } + std::fs::write(&self.path, contents)?; + *written = generation; + Ok(()) + } +} + +/// The cookie jar shared by every request the plugin sends. +/// +/// Get it with [`Http::cookie_jar`](crate::Http::cookie_jar). It implements +/// [`reqwest::cookie::CookieStore`], so it can be given to your own [`reqwest`] clients with +/// [`ClientBuilder::cookie_provider`](reqwest::ClientBuilder::cookie_provider) to share the +/// session with the webview: +/// +/// ```rust,no_run +/// use tauri_plugin_http::HttpExt; +/// +/// # fn f(app: &tauri::AppHandle) -> Result<(), Box> { +/// let client = tauri_plugin_http::reqwest::Client::builder() +/// .cookie_provider(app.http().cookie_jar()) +/// .build()?; +/// # Ok(()) +/// # } +/// ``` +/// +/// The jar holds the session cookies of every request, so any Rust code with access to it can read +/// and use them. +/// +/// Persistent cookies are written to a `.cookies` file in the application cache directory. The file +/// is updated in the background whenever the jar changes, and once more, synchronously, when the +/// application exits. Use [`CookieJar::save`] to write it immediately. +pub struct CookieJar { store: Mutex, - save_task: Mutex>, + file: Arc, +} + +impl std::fmt::Debug for CookieJar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // the cookies are session secrets, so they are left out + f.debug_struct("CookieJar") + .field("path", &self.file.path) + .finish_non_exhaustive() + } } -impl CookieStoreMutex { - /// Create a new [`CookieStoreMutex`] from an existing [`cookie_store::CookieStore`]. - pub fn new(path: PathBuf, cookie_store: CookieStore) -> CookieStoreMutex { - CookieStoreMutex { - path, +impl CookieJar { + pub(crate) fn new(path: PathBuf, cookie_store: CookieStore) -> CookieJar { + CookieJar { store: Mutex::new(cookie_store), - save_task: Default::default(), + file: Arc::new(CookieFile { + path, + latest: AtomicU64::new(0), + written: Mutex::new(0), + }), } } - pub fn load( + pub(crate) fn load( path: PathBuf, reader: R, - ) -> cookie_store::Result { + ) -> cookie_store::Result { cookie_store::serde::load(reader, |c| serde_json::from_str(c)) - .map(|store| CookieStoreMutex::new(path, store)) + .map(|store| CookieJar::new(path, store)) + } + + /// Removes every cookie from the jar. + /// + /// The change is persisted in the background, like any other change to the jar. + pub fn clear(&self) { + self.store.lock().unwrap().clear(); + self.request_save(); } - fn cookies_to_str(&self) -> Result { - let mut cookies = Vec::new(); - for cookie in self + /// Removes the cookie with the given `domain`, `path` and `name`, returning whether it existed. + /// + /// The change is persisted in the background, like any other change to the jar. + pub fn remove(&self, domain: &str, path: &str, name: &str) -> bool { + let removed = self .store .lock() - .expect("poisoned cookie jar mutex") - .iter_unexpired() - { - if cookie.is_persistent() { - cookies.push(cookie.clone()); - } + .unwrap() + .remove(domain, path, name) + .is_some(); + if removed { + self.request_save(); } - serde_json::to_string(&cookies) + removed } - pub fn request_save(&self) -> cookie_store::Result> { - let cookie_str = self.cookies_to_str()?; - let path = self.path.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - let task = tauri::async_runtime::spawn(async move { - match tokio::fs::write(&path, &cookie_str).await { - Ok(()) => { - let _ = tx.send(()); - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("failed to save cookie jar: {_e}"); - } + /// Writes the persistent cookies to disk now, blocking until the file is written. + pub fn save(&self) -> crate::Result<()> { + let (generation, contents) = self.snapshot()?; + self.file.write(generation, &contents, false)?; + Ok(()) + } + + /// Serializes the persistent cookies and gives the snapshot a new generation. + fn snapshot(&self) -> serde_json::Result<(u64, String)> { + // the generation is taken under the store lock so it follows the order of the changes + let store = self.store.lock().unwrap(); + let cookies = store + .iter_unexpired() + .filter(|cookie| cookie.is_persistent()) + .collect::>(); + let contents = serde_json::to_string(&cookies)?; + let generation = self.file.latest.fetch_add(1, Ordering::SeqCst) + 1; + Ok((generation, contents)) + } + + /// Writes the persistent cookies to disk in the background. + fn request_save(&self) { + let (generation, contents) = match self.snapshot() { + Ok(snapshot) => snapshot, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); + return; + } + }; + let file = self.file.clone(); + tauri::async_runtime::spawn_blocking(move || { + if let Err(_e) = file.write(generation, &contents, true) { + #[cfg(feature = "tracing")] + tracing::error!("failed to save cookie jar: {_e}"); } }); - self.save_task - .lock() - .unwrap() - .replace(CancellableTask(task)); - Ok(rx) } } -impl reqwest::cookie::CookieStore for CookieStoreMutex { +impl reqwest::cookie::CookieStore for CookieJar { fn set_cookies(&self, cookie_headers: &mut dyn Iterator, url: &url::Url) { set_cookies(&mut self.store.lock().unwrap(), cookie_headers, url); // try to persist cookies immediately asynchronously - if let Err(_e) = self.request_save() { - #[cfg(feature = "tracing")] - tracing::error!("failed to save cookie jar: {_e}"); - } + self.request_save(); } fn cookies(&self, url: &url::Url) -> Option { @@ -123,11 +215,151 @@ impl reqwest::cookie::CookieStore for CookieStoreMutex { } } -#[derive(Debug)] -struct CancellableTask(tauri::async_runtime::JoinHandle<()>); +#[cfg(test)] +mod tests { + use std::{ + io::{BufRead, BufReader, Write}, + net::TcpListener, + sync::Arc, + }; + + use super::CookieJar; + + /// Test server where `/login` sets a persistent `session` cookie, and every other path answers + /// with the `Cookie` header it received. + fn spawn_server() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = stream.unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + let mut cookie = String::new(); + loop { + let mut header = String::new(); + reader.read_line(&mut header).unwrap(); + if matches!(header.as_str(), "" | "\r\n" | "\n") { + break; + } + if let Some(value) = header.strip_prefix("cookie: ") { + cookie = value.trim().to_string(); + } + } + + let path = request_line.split(' ').nth(1).unwrap_or("/"); + let response = if path == "/login" { + "HTTP/1.1 200 OK\r\nSet-Cookie: session=abc; Max-Age=3600\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string() + } else { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{cookie}", + cookie.len() + ) + }; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + port + } + + fn get(jar: &Arc, url: &str) -> String { + let client = reqwest::Client::builder() + .cookie_provider(jar.clone()) + .build() + .unwrap(); + tauri::async_runtime::block_on(async move { + client.get(url).send().await.unwrap().text().await.unwrap() + }) + } + + fn temp_jar(name: &str) -> (std::path::PathBuf, Arc) { + let path = std::env::temp_dir().join(format!( + "tauri-plugin-http-{name}-{}.cookies", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let jar = Arc::new(CookieJar::new(path.clone(), Default::default())); + (path, jar) + } + + #[test] + fn clients_sharing_the_jar_share_the_session() { + let port = spawn_server(); + let (path, jar) = temp_jar("shared"); + + // the plugin's client logs in, then a separate client sends the session cookie + get(&jar, &format!("http://localhost:{port}/login")); + assert_eq!( + get(&jar, &format!("http://localhost:{port}/me")), + "session=abc" + ); + + jar.save().unwrap(); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.contains("abc")); + + // removing a cookie that does not exist is a no-op + assert!(!jar.remove("localhost", "/", "missing")); + assert!(jar.remove("localhost", "/", "session")); + assert_eq!(get(&jar, &format!("http://localhost:{port}/me")), ""); + + get(&jar, &format!("http://localhost:{port}/login")); + jar.clear(); + assert_eq!(get(&jar, &format!("http://localhost:{port}/me")), ""); + + jar.save().unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "[]"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn saved_cookies_are_loaded_back() { + let port = spawn_server(); + let (path, jar) = temp_jar("reload"); + + get(&jar, &format!("http://localhost:{port}/login")); + jar.save().unwrap(); + + let file = std::fs::File::open(&path).unwrap(); + let reloaded = Arc::new(CookieJar::load(path.clone(), BufReader::new(file)).unwrap()); + assert_eq!( + get(&reloaded, &format!("http://localhost:{port}/me")), + "session=abc" + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn debug_output_leaves_the_cookies_out() { + let port = spawn_server(); + let (path, jar) = temp_jar("debug"); + + get(&jar, &format!("http://localhost:{port}/login")); + let debug = format!("{jar:?}"); + assert!(debug.contains("CookieJar"), "{debug}"); + assert!(!debug.contains("abc"), "{debug}"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn stale_snapshots_are_not_written() { + let (path, jar) = temp_jar("stale"); + + let (old_generation, old) = jar.snapshot().unwrap(); + jar.save().unwrap(); + std::fs::write(&path, "newer").unwrap(); + + // an older background save finishing late must not replace the newer file + jar.file.write(old_generation, &old, true).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "newer"); -impl Drop for CancellableTask { - fn drop(&mut self) { - self.0.abort(); + let _ = std::fs::remove_file(&path); } } diff --git a/plugins/http/src/scope.rs b/plugins/http/src/scope.rs index c39540e75b..2cca723228 100644 --- a/plugins/http/src/scope.rs +++ b/plugins/http/src/scope.rs @@ -14,7 +14,7 @@ pub struct Entry { pub url: UrlPattern, } -fn parse_url_pattern(s: &str) -> Result { +pub(crate) fn parse_url_pattern(s: &str) -> Result { let mut init = urlpattern::UrlPatternInit::parse_constructor_string::(s, None)?; if init.search.as_ref().map(|p| p.is_empty()).unwrap_or(true) { init.search.replace("*".to_string());