diff --git a/.changes/http-spki-pinning.md b/.changes/http-spki-pinning.md new file mode 100644 index 0000000000..23454b1e09 --- /dev/null +++ b/.changes/http-spki-pinning.md @@ -0,0 +1,6 @@ +--- +"http": minor +"http-js": minor +--- + +Add an optional native Rustls SPKI pinning transport extension with exact-host pin sets, backup pins for key rotation, normal WebPKI validation, and fail-closed redirect coverage. diff --git a/.changes/http-transport-extensions.md b/.changes/http-transport-extensions.md new file mode 100644 index 0000000000..1b4b9ffff2 --- /dev/null +++ b/.changes/http-transport-extensions.md @@ -0,0 +1,6 @@ +--- +"http": minor +"http-js": minor +--- + +Add native transport extension hooks for request validation, final `reqwest::ClientBuilder` configuration, setup, and structured transport error mapping. diff --git a/Cargo.lock b/Cargo.lock index 15f3e418aa..731a835812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,9 +213,12 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" name = "api" version = "2.0.55" dependencies = [ + "base64 0.22.1", "log", + "rcgen", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-automation", @@ -248,6 +251,7 @@ dependencies = [ "tauri-plugin-window-state", "time", "tiny_http", + "webpki-roots 1.0.6", ] [[package]] @@ -355,6 +359,45 @@ dependencies = [ "zbus", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.12", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "synstructure 0.13.1", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -1488,6 +1531,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.4.0" @@ -3938,6 +3995,16 @@ dependencies = [ "serde", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -4348,6 +4415,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -4562,6 +4638,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5072,6 +5158,19 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "read-progress-stream" version = "1.0.0" @@ -5386,6 +5485,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -6885,15 +6993,19 @@ dependencies = [ name = "tauri-plugin-http" version = "2.8.2" dependencies = [ + "base64 0.22.1", "bytes", "cookie_store", "data-url", "http", + "rcgen", "regex", "reqwest 0.12.28", + "rustls", "schemars", "serde", "serde_json", + "sha2", "tauri", "tauri-plugin", "tauri-plugin-fs", @@ -6902,6 +7014,8 @@ dependencies = [ "tracing", "url", "urlpattern", + "webpki-roots 1.0.6", + "x509-parser", ] [[package]] @@ -9143,6 +9257,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.12", + "time", +] + [[package]] name = "xattr" version = "1.5.0" @@ -9159,6 +9290,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index 4cf3b13ba1..33d54257c5 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -18,6 +18,10 @@ tauri-build = { workspace = true, features = ["codegen", "isolation"] } serde_json = { workspace = true } serde = { workspace = true } tiny_http = "0.12" +base64 = "0.22" +rcgen = "0.13" +sha2 = "0.10" +webpki-roots = "1" time = "0.3" log = { workspace = true } tauri-plugin-log = { path = "../../../plugins/log", version = "2.10.1" } @@ -29,6 +33,7 @@ tauri-plugin-dialog = { path = "../../../plugins/dialog", version = "2.8.2" } tauri-plugin-http = { path = "../../../plugins/http", features = [ "multipart", "cookies", + "spki-pinning", ], version = "2.8.2" } tauri-plugin-notification = { path = "../../../plugins/notification", version = "2.6.0" } tauri-plugin-os = { path = "../../../plugins/os", version = "2.4.0" } diff --git a/examples/api/src-tauri/capabilities/base.json b/examples/api/src-tauri/capabilities/base.json index 518fc1248f..4d8d63b327 100644 --- a/examples/api/src-tauri/capabilities/base.json +++ b/examples/api/src-tauri/capabilities/base.json @@ -14,6 +14,12 @@ }, { "url": "http://127.0.0.1:3004" + }, + { + "url": "https://localhost:3005" + }, + { + "url": "https://localhost:3006" } ] }, diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index 9b08316180..d2a29e2cef 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT mod cmd; +mod spki; #[cfg(desktop)] mod tray; @@ -48,7 +49,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() + .extension(spki::pinning()) + .build(), + ) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_process::init()) diff --git a/examples/api/src-tauri/src/spki.rs b/examples/api/src-tauri/src/spki.rs new file mode 100644 index 0000000000..92cd6d977d --- /dev/null +++ b/examples/api/src-tauri/src/spki.rs @@ -0,0 +1,128 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! SPKI pinning for the http plugin, and the local HTTPS servers the e2e suite +//! (packages/api-e2e) checks it against. +//! +//! A throwaway CA issues two `localhost` certificates with different keys. Only +//! the first key is pinned, so `https://localhost:3005` (pinned key) is +//! accepted and `https://localhost:3006` (other key) is rejected even though +//! its chain is valid. Other hosts and plain HTTP keep working as before. + +use std::{ + io::{BufRead, BufReader, Write}, + net::TcpListener, + sync::Arc, +}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair}; +use sha2::{Digest, Sha256}; +use tauri_plugin_http::rustls::{ + self, RootCertStore, ServerConfig, ServerConnection, StreamOwned, + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}, +}; + +const PINNED_ADDR: &str = "localhost:3005"; +const UNPINNED_KEY_ADDR: &str = "localhost:3006"; + +/// Returns the pinning extension and starts the HTTPS servers it is tested against. +pub fn pinning() -> tauri_plugin_http::SpkiPinning { + let ca_key = KeyPair::generate().expect("failed to generate the test CA key"); + let mut ca_params = CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let ca = ca_params + .self_signed(&ca_key) + .expect("failed to create the test CA"); + + let leaf = |key: &KeyPair| -> CertificateDer<'static> { + CertificateParams::new(vec!["localhost".to_string()]) + .unwrap() + .signed_by(key, &ca, &ca_key) + .expect("failed to issue a test certificate") + .der() + .clone() + }; + let pinned_key = KeyPair::generate().expect("failed to generate a test key"); + let other_key = KeyPair::generate().expect("failed to generate a test key"); + + serve(PINNED_ADDR, leaf(&pinned_key), &pinned_key); + serve(UNPINNED_KEY_ADDR, leaf(&other_key), &other_key); + + // Mozilla's roots for every other host, plus the test CA + let mut roots = RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }; + roots + .add(ca.der().clone()) + .expect("failed to trust the test CA"); + + let pin = format!( + "sha256/{}", + STANDARD.encode(Sha256::digest(pinned_key.public_key_der())) + ); + tauri_plugin_http::SpkiPinning::new() + .pin("localhost", pin) + .expect("invalid test pin") + .root_certificates(roots) + .allow_unpinned_hosts() + .allow_http() +} + +/// Serves HTTPS on `addr`: `/redirect` redirects to the server with the +/// unpinned key, any other path answers `200 pinned`. +fn serve(addr: &'static str, cert: CertificateDer<'static>, key: &KeyPair) { + let config = + ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![cert], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())), + ) + .expect("invalid test certificate"); + let config = Arc::new(config); + + std::thread::spawn(move || { + let listener = match TcpListener::bind(addr) { + Ok(listener) => listener, + Err(e) => { + eprintln!("failed to bind {addr}: {e}"); + return; + } + }; + for stream in listener.incoming().flatten() { + let config = config.clone(); + std::thread::spawn(move || { + let Ok(connection) = ServerConnection::new(config) else { + return; + }; + let mut stream = BufReader::new(StreamOwned::new(connection, stream)); + + // a failed handshake (the client rejected our key) surfaces here + let mut request_line = String::new(); + if stream.read_line(&mut request_line).is_err() { + return; + } + let mut line = String::new(); + while stream.read_line(&mut line).is_ok_and(|n| n > 2) { + line.clear(); + } + + let response = if request_line.starts_with("GET /redirect ") { + format!( + "HTTP/1.1 302 Found\r\nLocation: https://{UNPINNED_KEY_ADDR}/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + } else { + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 6\r\nConnection: close\r\n\r\npinned".to_string() + }; + let stream = stream.get_mut(); + let _ = stream.write_all(response.as_bytes()); + stream.conn.send_close_notify(); + let _ = stream.flush(); + }); + } + }); +} diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md index 44463ccc38..4714599941 100644 --- a/packages/api-e2e/README.md +++ b/packages/api-e2e/README.md @@ -55,7 +55,9 @@ in the core repository; the `@tauri-apps/api` modules themselves are covered the on the host's loopback; the iOS simulator shares that network stack, and on Android the mobile config runs `adb reverse` so the same `127.0.0.1` URL works on the device. The `http` specs use the echo server the example app itself spawns on port 3003, since that - is the only `http://` origin in the example's http scope. The fixture server also has a + is the only `http://` origin in the example's http scope. The SPKI pinning specs use two + HTTPS servers the example also spawns, on ports 3005 and 3006, with `localhost` certificates + from a CA it generates at startup; it pins the key of the first one only. The fixture server also has a WebSocket echo endpoint (`ws://127.0.0.1:3004/ws`) for the `websocket` specs. ## What is (not) covered @@ -73,7 +75,7 @@ skipped on desktop, and the rest run everywhere with the odd test gated. | `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. | +| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures, SPKI pinning (pinned key, other key, redirect). | 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. | diff --git a/packages/api-e2e/test/specs/http.spec.ts b/packages/api-e2e/test/specs/http.spec.ts index 5506227ca2..7b9e71e3e6 100644 --- a/packages/api-e2e/test/specs/http.spec.ts +++ b/packages/api-e2e/test/specs/http.spec.ts @@ -181,4 +181,49 @@ describePlugin('http', () => { ) expect(message).toMatch(/error sending request/) }) + + describe('SPKI pinning', () => { + // The example pins `localhost` to the key of the server on port 3005. The + // server on 3006 has a certificate from the same trusted test CA but a + // different key, so only the pin check can reject it. + const pinnedServer = 'https://localhost:3005' + const otherKeyServer = 'https://localhost:3006' + + // Resolves to the response body, or to the structured error the pinning + // extension rejects with (an object, which `tauriError` would stringify). + const outcome = (url: string) => + tauri(async (api, url) => { + try { + const response = await api.http.fetch(url) + return { status: response.status, body: await response.text() } + } catch (error) { + return { error: error as unknown } + } + }, url) + + it('accepts a server presenting the pinned key', async () => { + expect(await outcome(pinnedServer)).toEqual({ + status: 200, + body: 'pinned' + }) + }) + + it('rejects a pinned host presenting another key', async () => { + expect(await outcome(otherKeyServer)).toEqual({ + error: { + code: 'SPKI_PIN_MISMATCH', + message: + 'the server certificate public key does not match a configured SPKI pin', + host: 'localhost' + } + }) + }) + + it('checks redirect targets against the pins', async () => { + const result = await outcome(`${pinnedServer}/redirect`) + expect(result).toMatchObject({ + error: { code: 'SPKI_PIN_MISMATCH', host: 'localhost' } + }) + }) + }) }) diff --git a/plugins/http/Cargo.toml b/plugins/http/Cargo.toml index b5adfc7855..f9c3a6b46b 100644 --- a/plugins/http/Cargo.toml +++ b/plugins/http/Cargo.toml @@ -47,8 +47,16 @@ url = { workspace = true } data-url = "0.3" cookie_store = { version = "0.22", optional = true, features = ["serde"] } bytes = { version = "1.9", optional = true } +base64 = { version = "0.22", optional = true } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"], optional = true } +sha2 = { version = "0.10", optional = true } +webpki-roots = { version = "1", optional = true } +x509-parser = { version = "0.18", optional = true } tracing = { workspace = true, optional = true } +[dev-dependencies] +rcgen = "0.13.2" + [features] default = ["rustls-tls", "http2", "charset", "system-proxy", "cookies"] multipart = ["reqwest/multipart"] @@ -74,6 +82,14 @@ charset = ["reqwest/charset"] # Deprecated, switch to system-proxy macos-system-configuration = ["reqwest/macos-system-configuration"] system-proxy = ["reqwest/system-proxy"] +spki-pinning = [ + "reqwest/rustls-tls-manual-roots", + "dep:base64", + "dep:rustls", + "dep:sha2", + "dep:webpki-roots", + "dep:x509-parser", +] unsafe-headers = [] tracing = ["dep:tracing"] diff --git a/plugins/http/README.md b/plugins/http/README.md index 32c619e1d9..9af4d2ea09 100644 --- a/plugins/http/README.md +++ b/plugins/http/README.md @@ -66,6 +66,106 @@ const response = await fetch('http://localhost:3003/users/2', { }) ``` +## Native transport extensions + +Trusted native code can extend the request transport while preserving the +existing JavaScript API. Extensions can validate request metadata, configure +the final `reqwest::ClientBuilder`, and map transport failures to structured +errors: + +```rust +use tauri_plugin_http::{ + ExtensionError, HttpTransportExtension, RequestContext, +}; + +struct CustomTransport; + +impl HttpTransportExtension for CustomTransport { + fn configure( + &self, + builder: reqwest::ClientBuilder, + _request: &RequestContext, + ) -> Result { + Ok(builder.https_only(true)) + } +} + +fn main() { + tauri::Builder::default() + .plugin( + tauri_plugin_http::Builder::new() + .extension(CustomTransport) + .build(), + ) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +Extensions run in registration order and are trusted to replace transport +settings. Request bodies are not exposed through `RequestContext`. + +### SPKI pinning + +Enable the `spki-pinning` Cargo feature to configure exact-host leaf public-key +pins entirely in native Rust: + +```toml +[dependencies] +tauri-plugin-http = { version = "2", features = ["spki-pinning"] } +``` + +```rust +let pinning = tauri_plugin_http::SpkiPinning::new() + // the server's current key + .pin("api.example.com", "sha256/GKu5vwG0evylkDs8zu/4Wf+m8cr/IYgVQ0Xl9UDBbS4=")? + // a backup key, so the server can rotate without breaking the app + .pin("api.example.com", "sha256/zKBL17i7q9TlJGD8gsqLZm2MeD6d3adsVR2bw7rC0qc=")?; + +tauri::Builder::default() + .plugin( + tauri_plugin_http::Builder::new() + .extension(pinning) + .build(), + ); +``` + +A pin is the base64 SHA-256 digest of the certificate's DER-encoded +SubjectPublicKeyInfo. For a live server: + +```sh +openssl s_client -connect api.example.com:443 -servername api.example.com /dev/null \ + | openssl x509 -pubkey -noout \ + | openssl pkey -pubin -outform der \ + | openssl dgst -sha256 -binary \ + | openssl base64 +``` + +Pinning failures reject the JavaScript `fetch` promise with a plain object +(not an `Error`) such as +`{ code: "SPKI_PIN_MISMATCH", message: "...", host: "api.example.com" }`. +The codes are `SPKI_PIN_MISMATCH`, `SPKI_HOST_NOT_PINNED`, +`SPKI_HTTP_NOT_ALLOWED`, `SPKI_DANGEROUS_SETTINGS` and +`SPKI_INITIALIZATION_FAILED`. + +Normal WebPKI chain and hostname validation runs before the leaf SPKI digest is +checked. By default every HTTPS host, including redirect targets, requires +configured pins. `allow_unpinned_hosts()` explicitly keeps normal WebPKI +validation for hosts without pins. The client is HTTPS-only by default so +redirects cannot downgrade to plain HTTP; `allow_http()` is an explicit native +opt-in. Dangerous certificate and hostname settings are rejected. + +Certificate chains are validated against Mozilla's root store (`webpki-roots`), +not the operating system's. To pin a server with a private or self-signed CA, +or to trust the system roots, pass your own store with +`root_certificates(tauri_plugin_http::rustls::RootCertStore)`. + +The TLS connection to an `https://` proxy, including one picked up from the +environment by the default `system-proxy` feature, is verified with the same +settings, so pin the proxy host too or use `allow_unpinned_hosts()`. Requests +tunneled through an `http://` proxy are still verified against the target's +pins. + ## Contributing PRs accepted. Please make sure to read the Contributing Guide before making a pull request. diff --git a/plugins/http/guest-js/index.ts b/plugins/http/guest-js/index.ts index 5d4e0945fd..3528c6af2d 100644 --- a/plugins/http/guest-js/index.ts +++ b/plugins/http/guest-js/index.ts @@ -143,7 +143,10 @@ const ERROR_REQUEST_CANCELLED = 'Request cancelled' * @param init The standard `fetch` request options, extended with the Rust client options from * {@linkcode ClientOptions}: `maxRedirections`, `connectTimeout`, `proxy` and `danger`. The * `signal` option can be used to abort the request. - * @returns A promise resolving to the `Response` of the request. + * @returns A promise resolving to the `Response` of the request. It rejects with an error message + * string when the request fails, or with the structured value of a native transport extension + * registered in Rust (for instance `{ code, message, host }` from SPKI pinning) when the extension + * rejects the request. * * @since 2.0.0 */ diff --git a/plugins/http/src/commands.rs b/plugins/http/src/commands.rs index 44af5bd544..a20501ded5 100644 --- a/plugins/http/src/commands.rs +++ b/plugins/http/src/commands.rs @@ -16,7 +16,7 @@ use tauri::{ use tokio::sync::oneshot::{Receiver, Sender, channel}; use crate::{ - Error, Http, Result, + Error, Http, RequestContext, Result, scope::{Entry, Scope}, }; @@ -231,7 +231,7 @@ fn map_request_error(error: reqwest::Error) -> Error { #[command] pub async fn fetch( webview: Webview, - state: State<'_, Http>, + state: State<'_, Http>, client_config: ClientConfig, command_scope: CommandScope, global_scope: GlobalScope, @@ -291,6 +291,33 @@ pub async fn fetch( return Err(Error::UrlNotAllowed(url)); } + let extensions = state.extensions.clone(); + let connect_timeout = connect_timeout.map(Duration::from_millis); + let (danger_accept_invalid_certs, danger_accept_invalid_hostnames) = danger + .as_ref() + .map(|settings| { + ( + settings.accept_invalid_certs, + settings.accept_invalid_hostnames, + ) + }) + .unwrap_or_default(); + let request_context = RequestContext::new( + method.clone(), + url.clone(), + headers.clone(), + data.is_some(), + connect_timeout, + max_redirections, + proxy.is_some(), + danger_accept_invalid_certs, + danger_accept_invalid_hostnames, + ); + + for extension in extensions.iter() { + extension.validate(&request_context)?; + } + let mut builder = reqwest::ClientBuilder::new(); if let Some(danger_config) = danger { @@ -314,7 +341,7 @@ pub async fn fetch( } if let Some(timeout) = connect_timeout { - builder = builder.connect_timeout(Duration::from_millis(timeout)); + builder = builder.connect_timeout(timeout); } let scope = state.config.scope_redirects.then_some(scope); @@ -329,6 +356,10 @@ pub async fn fetch( builder = builder.cookie_provider(state.cookies_jar.clone()); } + for extension in extensions.iter() { + builder = extension.configure(builder, &request_context)?; + } + let mut request = builder.build()?.request(method.clone(), url); // POST and PUT requests should always have a 0 length content-length, @@ -377,7 +408,17 @@ pub async fn fetch( #[cfg(feature = "tracing")] tracing::trace!("{:?}", request); - let fut = async move { request.send().await.map_err(map_request_error) }; + let fut = async move { + request.send().await.map_err(|error| { + extensions + .iter() + .find_map(|extension| { + extension.map_transport_error(&error, &request_context) + }) + .map(Error::from) + .unwrap_or_else(|| map_request_error(error)) + }) + }; let mut resources_table = webview.resources_table(); let rid = resources_table.add_request(Box::pin(fut)); diff --git a/plugins/http/src/error.rs b/plugins/http/src/error.rs index 3f68f1cdb6..f4f77c580c 100644 --- a/plugins/http/src/error.rs +++ b/plugins/http/src/error.rs @@ -7,7 +7,8 @@ use url::Url; /// Errors that can happen while using the HTTP plugin. /// -/// The error is serialized to the frontend as its [`Display`](std::fmt::Display) string. +/// The error is serialized to the frontend as its [`Display`](std::fmt::Display) string, except +/// for [`Error::Extension`], which is serialized as the extension's JSON value. #[derive(Debug, thiserror::Error)] pub enum Error { /// JSON serialization or deserialization error. @@ -21,6 +22,10 @@ pub enum Error { /// request or reading the response body. #[error(transparent)] Network(#[from] reqwest::Error), + /// Error returned by a [`HttpTransportExtension`](crate::HttpTransportExtension) that rejected + /// the request or classified its transport failure. + #[error(transparent)] + Extension(#[from] crate::ExtensionError), /// Error from the [`http`] crate, raised while building the response of a `data:` URL. #[error(transparent)] Http(#[from] http::Error), @@ -78,9 +83,42 @@ impl Serialize for Error { where S: Serializer, { - serializer.serialize_str(self.to_string().as_ref()) + match self { + Self::Extension(error) => error.value().serialize(serializer), + _ => serializer.serialize_str(self.to_string().as_ref()), + } } } /// Alias for a [`Result`](std::result::Result) with the error type [`Error`]. pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_errors_keep_their_json_shape() { + let error = Error::Extension(crate::ExtensionError::from_value(serde_json::json!({ + "code": "POLICY_REJECTED", + "host": "example.test" + }))); + + assert_eq!( + serde_json::to_value(error).unwrap(), + serde_json::json!({ + "code": "POLICY_REJECTED", + "host": "example.test" + }) + ); + } + + #[test] + fn existing_errors_remain_strings() { + let error = Error::RequestCanceled; + assert_eq!( + serde_json::to_value(error).unwrap(), + serde_json::Value::String("Request canceled".into()) + ); + } +} diff --git a/plugins/http/src/extension.rs b/plugins/http/src/extension.rs new file mode 100644 index 0000000000..faa7b38680 --- /dev/null +++ b/plugins/http/src/extension.rs @@ -0,0 +1,208 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{fmt, sync::Arc, time::Duration}; + +use http::{HeaderMap, Method}; +use serde::Serialize; +use tauri::{AppHandle, Runtime}; +use url::Url; + +/// A transport extension invoked for every scoped HTTP(S) request. +/// +/// Extensions are trusted native components registered with [`crate::Builder`]. +/// They can validate request metadata before a client is built, configure the +/// final [`reqwest::ClientBuilder`], and classify transport errors without +/// changing the JavaScript API. +pub trait HttpTransportExtension: Send + Sync + 'static { + /// Initializes the extension when the plugin is set up. + fn setup(&self, _app: &AppHandle) -> Result<(), ExtensionError> { + Ok(()) + } + + /// Validates a request before any network client is built. + fn validate(&self, _request: &RequestContext) -> Result<(), ExtensionError> { + Ok(()) + } + + /// Configures the final request client after the plugin has applied its + /// timeout, redirect, proxy, cookie, and feature-gated TLS settings. + fn configure( + &self, + builder: reqwest::ClientBuilder, + _request: &RequestContext, + ) -> Result { + Ok(builder) + } + + /// Maps an already-failed transport error to an extension-owned error. + /// + /// Returning `None` preserves the original [`reqwest::Error`]. + fn map_transport_error( + &self, + _error: &reqwest::Error, + _request: &RequestContext, + ) -> Option { + None + } +} + +/// An extension-owned error value forwarded through the plugin IPC boundary. +/// +/// The HTTP plugin treats the JSON value as opaque. This lets extensions keep +/// their own stable error contracts without adding extension-specific variants +/// to the plugin's error enum. +#[derive(Debug, Clone)] +pub struct ExtensionError(serde_json::Value); + +impl ExtensionError { + /// Creates an extension error from any serializable value. + pub fn new(value: impl Serialize) -> Result { + serde_json::to_value(value).map(Self) + } + + /// Creates an extension error from an existing JSON value. + pub fn from_value(value: serde_json::Value) -> Self { + Self(value) + } + + /// Returns the opaque JSON value supplied by the extension. + pub fn value(&self) -> &serde_json::Value { + &self.0 + } +} + +impl fmt::Display for ExtensionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0.to_string()) + } +} + +impl std::error::Error for ExtensionError {} + +/// Immutable request metadata exposed to transport extensions. +/// +/// The request body is never exposed. Extensions only receive whether a body +/// exists, along with the URL, method, filtered headers, and client options. +#[derive(Debug, Clone)] +pub struct RequestContext { + method: Method, + url: Url, + headers: HeaderMap, + has_body: bool, + connect_timeout: Option, + max_redirections: Option, + proxy_requested: bool, + danger_accept_invalid_certs: bool, + danger_accept_invalid_hostnames: bool, +} + +impl RequestContext { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + method: Method, + url: Url, + headers: HeaderMap, + has_body: bool, + connect_timeout: Option, + max_redirections: Option, + proxy_requested: bool, + danger_accept_invalid_certs: bool, + danger_accept_invalid_hostnames: bool, + ) -> Self { + Self { + method, + url, + headers, + has_body, + connect_timeout, + max_redirections, + proxy_requested, + danger_accept_invalid_certs, + danger_accept_invalid_hostnames, + } + } + + pub fn method(&self) -> &Method { + &self.method + } + + pub fn url(&self) -> &Url { + &self.url + } + + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + pub fn has_body(&self) -> bool { + self.has_body + } + + pub fn connect_timeout(&self) -> Option { + self.connect_timeout + } + + pub fn max_redirections(&self) -> Option { + self.max_redirections + } + + pub fn proxy_requested(&self) -> bool { + self.proxy_requested + } + + pub fn danger_accept_invalid_certs(&self) -> bool { + self.danger_accept_invalid_certs + } + + pub fn danger_accept_invalid_hostnames(&self) -> bool { + self.danger_accept_invalid_hostnames + } +} + +pub(crate) type ExtensionList = Arc>>>; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extension_error_preserves_structured_json() { + let error = ExtensionError::new(serde_json::json!({ + "code": "POLICY_REJECTED", + "host": "example.test" + })) + .unwrap(); + + assert_eq!( + error.to_string(), + r#"{"code":"POLICY_REJECTED","host":"example.test"}"# + ); + assert_eq!(error.value()["code"], "POLICY_REJECTED"); + } + + #[test] + fn request_context_exposes_owned_request_metadata() { + let context = RequestContext::new( + Method::POST, + Url::parse("https://example.test/path").unwrap(), + HeaderMap::new(), + true, + Some(Duration::from_secs(2)), + Some(3), + true, + false, + false, + ); + + assert_eq!(context.method(), Method::POST); + assert_eq!(context.url().host_str(), Some("example.test")); + assert!(context.has_body()); + assert_eq!(context.connect_timeout(), Some(Duration::from_secs(2))); + assert_eq!(context.max_redirections(), Some(3)); + assert!(context.proxy_requested()); + assert!(!context.danger_accept_invalid_certs()); + assert!(!context.danger_accept_invalid_hostnames()); + } +} diff --git a/plugins/http/src/lib.rs b/plugins/http/src/lib.rs index e6db850f07..12b2d94efb 100644 --- a/plugins/http/src/lib.rs +++ b/plugins/http/src/lib.rs @@ -29,7 +29,7 @@ //! - **brotli**: Provides response body brotli decompression. //! - **zstd**: Provides response body zstd decompression. //! - **deflate**: Provides response body deflate decompression. -//! - **json**: Provides serialization and deserialization for JSON bodies. +//! - **json**: Provides serialization and deserialization of JSON bodies. //! - **multipart**: Provides functionality for multipart forms. //! - **stream**: Adds support for futures::Stream. //! - **socks**: Provides SOCKS5 proxy support. @@ -43,6 +43,7 @@ //! - **tracing**: Adds request, response, and cookie-store diagnostics through `tracing`. //! - **unsafe-headers**: Allows webview requests to send any headers. //! - **dangerous-settings**: Allows dangerous client settings such as accepting invalid certificates or hostnames. +//! - **spki-pinning**: Adds the native [`SpkiPinning`] transport extension for exact-host leaf SPKI pinning with Rustls. //! //! ## Configuration //! @@ -60,101 +61,163 @@ //! ``` pub use reqwest; + +use std::sync::Arc; + use tauri::{ Manager, Runtime, - plugin::{Builder, TauriPlugin}, + plugin::{Builder as PluginBuilder, TauriPlugin}, }; pub use config::Config; pub use error::{Error, Result}; +pub use extension::{ExtensionError, HttpTransportExtension, RequestContext}; mod commands; mod config; mod error; +mod extension; #[cfg(feature = "cookies")] mod reqwest_cookie_store; mod scope; +#[cfg(feature = "spki-pinning")] +mod spki_pinning; + +#[cfg(feature = "spki-pinning")] +pub use rustls; +#[cfg(feature = "spki-pinning")] +pub use spki_pinning::{SpkiPinError, SpkiPinning}; #[cfg(feature = "cookies")] const COOKIES_FILENAME: &str = ".cookies"; -pub(crate) struct Http { +pub(crate) struct Http { pub(crate) config: Config, + extensions: extension::ExtensionList, #[cfg(feature = "cookies")] - cookies_jar: std::sync::Arc, + cookies_jar: Arc, } -/// Initializes the plugin. -/// -/// 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. -/// -/// With the `cookies` Cargo feature (enabled by default), a cookie jar is loaded from a `.cookies` -/// file in the application cache directory on setup and written back to it when the application -/// exits. A jar that cannot be read is replaced by an empty one. -/// -/// 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(), +/// Builds the HTTP plugin with optional native transport extensions. +pub struct Builder { + extensions: Vec>>, +} + +impl Default for Builder { + fn default() -> Self { + Self { + extensions: Vec::new(), + } + } +} + +impl Builder { + /// Creates an HTTP plugin builder without transport extensions. + pub fn new() -> Self { + Self::default() + } + + /// Registers a native transport extension. + /// + /// Extensions run in registration order. Only trusted native code should + /// be registered because extensions can replace the request transport. + pub fn extension(mut self, extension: impl HttpTransportExtension) -> Self { + self.extensions.push(Arc::new(extension)); + self + } + + /// Registers a shared native transport extension. + pub fn extension_arc(mut self, extension: Arc>) -> Self { + self.extensions.push(extension); + self + } + + /// Builds the HTTP plugin. + /// + /// 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. + /// + /// With the `cookies` Cargo feature (enabled by default), a cookie jar is loaded from a `.cookies` + /// file in the application cache directory on setup and written back to it when the application + /// exits. A jar that cannot be read is replaced by an empty one. + pub fn build(self) -> TauriPlugin> { + let extensions = Arc::new(self.extensions); + + PluginBuilder::>::new("http") + .setup(|app, api| { #[cfg(feature = "cookies")] - cookies_jar: std::sync::Arc::new(cookies_jar), - }; + let cookies_jar = { + use crate::reqwest_cookie_store::*; + use std::fs::File; + use std::io::BufReader; - app.manage(state); + let cache_dir = app.path().app_cache_dir()?; + std::fs::create_dir_all(&cache_dir)?; - Ok(()) - }) - .on_event(|app, event| { - #[cfg(feature = "cookies")] - if let tauri::RunEvent::Exit = event { - let state = app.state::(); + let path = cache_dir.join(COOKIES_FILENAME); + let file = File::options() + .create(true) + .append(true) + .read(true) + .open(&path)?; - match state.cookies_jar.request_save() { - Ok(rx) => { - let _ = rx.recv(); - } - Err(_e) => { + let reader = BufReader::new(file); + CookieStoreMutex::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" + ); + CookieStoreMutex::new(path, Default::default()) + }) + }; + + for extension in extensions.iter() { + extension.setup(app)?; + } + + let state = Http { + config: api.config().clone().unwrap_or_default(), + extensions, + #[cfg(feature = "cookies")] + cookies_jar: 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) => { + #[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() + } +} + +/// Initializes the plugin with the default transport behavior. +/// +/// See [`Builder::build`] for the configuration and cookie behavior. +/// +/// Register it on the Tauri builder with `.plugin(tauri_plugin_http::init())`. +pub fn init() -> TauriPlugin> { + Builder::new().build() } diff --git a/plugins/http/src/spki_pinning.rs b/plugins/http/src/spki_pinning.rs new file mode 100644 index 0000000000..23928af17b --- /dev/null +++ b/plugins/http/src/spki_pinning.rs @@ -0,0 +1,655 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, OnceLock}, +}; + +use crate::{ExtensionError, HttpTransportExtension, RequestContext}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use rustls::{ + DigitallySignedStruct, Error as RustlsError, RootCertStore, SignatureScheme, + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + pki_types::{CertificateDer, ServerName, UnixTime}, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +const ERROR_CODE_PIN_MISMATCH: &str = "SPKI_PIN_MISMATCH"; +const ERROR_CODE_HOST_NOT_PINNED: &str = "SPKI_HOST_NOT_PINNED"; +const ERROR_CODE_HTTP_NOT_ALLOWED: &str = "SPKI_HTTP_NOT_ALLOWED"; +const ERROR_CODE_DANGEROUS_SETTINGS: &str = "SPKI_DANGEROUS_SETTINGS"; +const ERROR_CODE_INITIALIZATION: &str = "SPKI_INITIALIZATION_FAILED"; + +/// Native SPKI pinning transport extension. +/// +/// Normal WebPKI chain and hostname validation runs before the leaf +/// certificate's SubjectPublicKeyInfo SHA-256 digest is compared with the +/// pins configured for the request host. +/// +/// Certificate chains are validated against Mozilla's root store +/// (`webpki-roots`) unless [`root_certificates`](Self::root_certificates) +/// replaces it. +/// +/// The TLS connection to an `https://` proxy, whether configured per request +/// or picked up from the environment by the `system-proxy` feature, is +/// verified with these settings too: pin the proxy host as well or use +/// [`allow_unpinned_hosts`](Self::allow_unpinned_hosts). Requests tunneled +/// through an `http://` proxy are still verified against the target's pins. +#[derive(Debug, Clone)] +pub struct SpkiPinning { + pins: Arc>>, + roots: Option>, + require_pins_for_all_hosts: bool, + allow_http: bool, + /// The TLS config built from the settings above on first use, shared by + /// every request. Builder methods that change those settings reset it. + tls: Arc>>, +} + +impl SpkiPinning { + /// Creates a fail-closed pinning extension. + /// + /// By default every HTTPS request, including redirect targets, must have at + /// least one configured pin. Use [`allow_unpinned_hosts`](Self::allow_unpinned_hosts) + /// to keep normal WebPKI validation for hosts without pins. + pub fn new() -> Self { + Self { + pins: Arc::new(HashMap::new()), + roots: None, + require_pins_for_all_hosts: true, + allow_http: false, + tls: Default::default(), + } + } + + /// Adds one `sha256/` SPKI pin for an exact DNS hostname. + /// + /// Register at least two pins per host before rotating a server key. + pub fn pin( + mut self, + host: impl AsRef, + pin: impl AsRef, + ) -> Result { + let host = normalize_host(host.as_ref())?; + let pin = parse_pin(pin.as_ref())?; + Arc::make_mut(&mut self.pins) + .entry(host) + .or_default() + .insert(pin); + self.tls = Default::default(); + Ok(self) + } + + /// Replaces the trusted root certificates used for chain validation. + /// + /// Use this to pin servers whose certificates are issued by a private or + /// self-signed CA, or to trust the operating system's roots (for example, + /// loaded with `rustls-native-certs`). Pins are checked after the chain is + /// validated against these roots. + pub fn root_certificates(mut self, roots: RootCertStore) -> Self { + self.roots = Some(Arc::new(roots)); + self.tls = Default::default(); + self + } + + /// Allows hosts without configured pins to use normal WebPKI validation. + pub fn allow_unpinned_hosts(mut self) -> Self { + self.require_pins_for_all_hosts = false; + self.tls = Default::default(); + self + } + + /// Allows plain HTTP requests and HTTPS-to-HTTP redirects. + /// + /// The default is HTTPS-only so a pinned request cannot silently leave the + /// TLS transport through a redirect. + pub fn allow_http(mut self) -> Self { + self.allow_http = true; + self + } +} + +impl Default for SpkiPinning { + fn default() -> Self { + Self::new() + } +} + +impl SpkiPinning { + fn tls_config(&self) -> Result { + self.tls + .get_or_init(|| { + build_tls_config( + self.pins.clone(), + self.roots.clone(), + self.require_pins_for_all_hosts, + ) + }) + .clone() + } + + fn validate_request(&self, request: &RequestContext) -> Result<(), ExtensionError> { + if request.danger_accept_invalid_certs() || request.danger_accept_invalid_hostnames() { + return Err(extension_error(PinningFailure::new( + ERROR_CODE_DANGEROUS_SETTINGS, + "dangerous TLS settings cannot be combined with SPKI pinning", + request.url().host_str(), + ))); + } + + if request.url().scheme() != "https" { + return if self.allow_http { + Ok(()) + } else { + Err(extension_error(PinningFailure::new( + ERROR_CODE_HTTP_NOT_ALLOWED, + "SPKI pinning is HTTPS-only unless plain HTTP is explicitly enabled", + request.url().host_str(), + ))) + }; + } + + if self.require_pins_for_all_hosts { + let host = pin_lookup_host(request.url().host_str().unwrap_or_default()); + if !self.pins.contains_key(&host) { + return Err(extension_error(PinningFailure::new( + ERROR_CODE_HOST_NOT_PINNED, + "no SPKI pins are configured for the request host", + Some(&host), + ))); + } + } + + Ok(()) + } +} + +impl HttpTransportExtension for SpkiPinning { + fn validate(&self, request: &RequestContext) -> Result<(), ExtensionError> { + self.validate_request(request) + } + + fn configure( + &self, + builder: reqwest::ClientBuilder, + _request: &RequestContext, + ) -> Result { + let tls = self.tls_config().map_err(|message| { + extension_error(PinningFailure::new( + ERROR_CODE_INITIALIZATION, + message, + None, + )) + })?; + Ok(builder + .https_only(!self.allow_http) + .use_preconfigured_tls(tls)) + } + + fn map_transport_error( + &self, + error: &reqwest::Error, + _request: &RequestContext, + ) -> Option { + // `https_only` stopped a redirect to a non-HTTPS URL + let downgrade = error + .url() + .filter(|url| !self.allow_http && error.is_redirect() && url.scheme() != "https"); + if let Some(url) = downgrade { + return Some(extension_error(PinningFailure::new( + ERROR_CODE_HTTP_NOT_ALLOWED, + "SPKI pinning is HTTPS-only unless plain HTTP is explicitly enabled", + url.host_str(), + ))); + } + + let rejection = pin_rejection(error)?; + Some(extension_error(match rejection { + PinRejection::Mismatch(host) => PinningFailure::new( + ERROR_CODE_PIN_MISMATCH, + "the server certificate public key does not match a configured SPKI pin", + Some(host.as_str()), + ), + PinRejection::HostNotPinned(host) => PinningFailure::new( + ERROR_CODE_HOST_NOT_PINNED, + "no SPKI pins are configured for the request host", + Some(host.as_str()), + ), + })) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SpkiPinError { + #[error("SPKI pin host must be a non-empty exact DNS hostname")] + InvalidHost, + #[error("SPKI pin must use the sha256/ format")] + InvalidFormat, + #[error("SPKI pin must decode to exactly 32 bytes")] + InvalidLength, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PinningFailure<'a> { + code: &'static str, + message: String, + host: Option<&'a str>, +} + +impl<'a> PinningFailure<'a> { + fn new(code: &'static str, message: impl Into, host: Option<&'a str>) -> Self { + Self { + code, + message: message.into(), + host, + } + } +} + +fn extension_error(error: PinningFailure<'_>) -> ExtensionError { + ExtensionError::new(error).expect("SPKI pinning errors must serialize") +} + +fn normalize_host(host: &str) -> Result { + let host = host.trim().trim_end_matches('.'); + if host.is_empty() || host.contains('*') { + return Err(SpkiPinError::InvalidHost); + } + match url::Host::parse(host).map_err(|_| SpkiPinError::InvalidHost)? { + url::Host::Domain(host) if !host.is_empty() => Ok(host.to_ascii_lowercase()), + url::Host::Ipv4(_) | url::Host::Ipv6(_) | url::Host::Domain(_) => { + Err(SpkiPinError::InvalidHost) + } + } +} + +/// The key a request or server name is looked up with: the same form +/// [`normalize_host`] stores pins under. +fn pin_lookup_host(host: &str) -> String { + host.trim_end_matches('.').to_ascii_lowercase() +} + +fn parse_pin(pin: &str) -> Result<[u8; 32], SpkiPinError> { + let encoded = pin + .strip_prefix("sha256/") + .ok_or(SpkiPinError::InvalidFormat)?; + let decoded = STANDARD + .decode(encoded) + .map_err(|_| SpkiPinError::InvalidFormat)?; + decoded.try_into().map_err(|_| SpkiPinError::InvalidLength) +} + +/// Why the pinned verifier rejected a certificate, carried through the TLS +/// stack as `rustls::Error::Other`. +#[derive(Debug, thiserror::Error)] +enum PinRejection { + #[error("the server certificate public key does not match a configured SPKI pin for {0}")] + Mismatch(String), + #[error("no SPKI pins are configured for {0}")] + HostNotPinned(String), +} + +impl From for RustlsError { + fn from(rejection: PinRejection) -> Self { + RustlsError::Other(rustls::OtherError(Arc::new(rejection))) + } +} + +/// Finds the [`PinRejection`] behind a transport error. +/// +/// The TLS stack wraps the rustls error in (nested) `io::Error`s, whose +/// `source()` skips the error they wrap, so those are unwrapped with +/// `get_ref()` instead. +fn pin_rejection<'a>(error: &'a (dyn std::error::Error + 'static)) -> Option<&'a PinRejection> { + let mut source = Some(error); + while let Some(current) = source { + let rejection = match current.downcast_ref::() { + Some(RustlsError::Other(other)) => other.0.downcast_ref::(), + _ => None, + }; + if rejection.is_some() { + return rejection; + } + source = match current.downcast_ref::() { + Some(error) => error + .get_ref() + .map(|error| error as &(dyn std::error::Error + 'static)), + None => current.source(), + }; + } + None +} + +#[derive(Debug)] +struct PinnedServerCertVerifier { + web_pki: Arc, + pins: Arc>>, + require_pins_for_all_hosts: bool, +} + +impl ServerCertVerifier for PinnedServerCertVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + self.web_pki.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + )?; + + let host = pin_lookup_host(&server_name.to_str()); + let Some(pins) = self.pins.get(&host) else { + return if self.require_pins_for_all_hosts { + Err(PinRejection::HostNotPinned(host).into()) + } else { + Ok(ServerCertVerified::assertion()) + }; + }; + + let (_, certificate) = x509_parser::parse_x509_certificate(end_entity.as_ref()) + .map_err(|_| RustlsError::General("failed to parse certificate SPKI".into()))?; + let digest: [u8; 32] = Sha256::digest(certificate.public_key().raw).into(); + if pins.contains(&digest) { + Ok(ServerCertVerified::assertion()) + } else { + Err(PinRejection::Mismatch(host).into()) + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.web_pki.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.web_pki.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.web_pki.supported_verify_schemes() + } +} + +fn build_tls_config( + pins: Arc>>, + roots: Option>, + require_pins_for_all_hosts: bool, +) -> Result { + let roots = roots.unwrap_or_else(|| { + Arc::new(RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }) + }); + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let web_pki = + rustls::client::WebPkiServerVerifier::builder_with_provider(roots, provider.clone()) + .build() + .map_err(|error| error.to_string())?; + let verifier = Arc::new(PinnedServerCertVerifier { + web_pki, + pins, + require_pins_for_all_hosts, + }); + + let mut config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|error| error.to_string())? + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); + // reqwest uses a preconfigured TLS config as-is, so it must advertise the + // ALPN protocols reqwest would otherwise set itself, or HTTP/2 is never negotiated. + config.alpn_protocols = alpn_protocols(); + Ok(config) +} + +fn alpn_protocols() -> Vec> { + vec![ + #[cfg(feature = "http2")] + b"h2".to_vec(), + b"http/1.1".to_vec(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair, KeyUsagePurpose}; + + const EXAMPLE_PIN: &str = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + + fn test_verifier( + pins: HashMap>, + ) -> (PinnedServerCertVerifier, CertificateDer<'static>, [u8; 32]) { + let ca_key = KeyPair::generate().unwrap(); + let mut ca_params = CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + ]; + let ca = ca_params.self_signed(&ca_key).unwrap(); + + let leaf_key = KeyPair::generate().unwrap(); + let leaf = CertificateParams::new(vec![ + "api.example.test".into(), + "redirect.example.test".into(), + ]) + .unwrap() + .signed_by(&leaf_key, &ca, &ca_key) + .unwrap(); + let leaf = leaf.der().clone(); + let (_, parsed) = x509_parser::parse_x509_certificate(leaf.as_ref()).unwrap(); + let pin = Sha256::digest(parsed.public_key().raw).into(); + + let mut roots = RootCertStore::empty(); + roots.add(ca.der().clone()).unwrap(); + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let web_pki = + rustls::client::WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider) + .build() + .unwrap(); + + ( + PinnedServerCertVerifier { + web_pki, + pins: Arc::new(pins), + require_pins_for_all_hosts: true, + }, + leaf, + pin, + ) + } + + fn verify( + verifier: &PinnedServerCertVerifier, + leaf: &CertificateDer<'_>, + host: &'static str, + ) -> Result { + verifier.verify_server_cert( + leaf, + &[], + &ServerName::try_from(host).unwrap(), + &[], + UnixTime::now(), + ) + } + + #[test] + fn accepts_multiple_pins_for_a_normalized_host() { + let extension = SpkiPinning::new() + .pin("API.Example.COM.", EXAMPLE_PIN) + .unwrap() + .pin( + "api.example.com", + "sha256/AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", + ) + .unwrap(); + + assert_eq!(extension.pins["api.example.com"].len(), 2); + } + + #[test] + fn validate_matches_fully_qualified_hosts() { + let extension = SpkiPinning::new() + .pin("api.example.com", EXAMPLE_PIN) + .unwrap(); + let request = |url: &str| { + RequestContext::new( + http::Method::GET, + url::Url::parse(url).unwrap(), + http::HeaderMap::new(), + false, + None, + None, + false, + false, + false, + ) + }; + + assert!( + extension + .validate_request(&request("https://api.example.com./v1")) + .is_ok() + ); + assert!( + extension + .validate_request(&request("https://API.example.com/v1")) + .is_ok() + ); + let error = extension + .validate_request(&request("https://other.example.com./v1")) + .unwrap_err(); + assert_eq!(error.value()["code"], ERROR_CODE_HOST_NOT_PINNED); + assert_eq!(error.value()["host"], "other.example.com"); + } + + #[test] + fn rejects_wildcards_ips_invalid_hosts_and_malformed_pins() { + for host in ["*.example.com", "127.0.0.1", "not a host"] { + assert!(matches!( + SpkiPinning::new().pin(host, EXAMPLE_PIN), + Err(SpkiPinError::InvalidHost) + )); + } + assert!(matches!( + SpkiPinning::new().pin("example.com", "AAAAAAAA"), + Err(SpkiPinError::InvalidFormat) + )); + } + + #[test] + fn webpki_and_hostname_validation_run_before_pin_matching() { + let (mut verifier, leaf, leaf_pin) = test_verifier(HashMap::new()); + Arc::make_mut(&mut verifier.pins) + .entry("api.example.test".into()) + .or_default() + .insert(leaf_pin); + + assert!(verify(&verifier, &leaf, "api.example.test").is_ok()); + + let hostname_error = verify(&verifier, &leaf, "wrong.example.test").unwrap_err(); + assert!(matches!( + hostname_error, + RustlsError::InvalidCertificate(rustls::CertificateError::NotValidForName) + | RustlsError::InvalidCertificate( + rustls::CertificateError::NotValidForNameContext { .. } + ) + )); + } + + #[test] + fn rejects_wrong_pins_and_unpinned_redirect_hosts() { + let mut pins = HashMap::new(); + pins.insert("api.example.test".into(), HashSet::from([[0; 32]])); + let (verifier, leaf, _) = test_verifier(pins); + + // as reqwest reports it: the rustls error inside two nested io::Errors + let wrapped = |error: RustlsError| -> Box { + Box::new(std::io::Error::other(std::io::Error::new( + std::io::ErrorKind::InvalidData, + error, + ))) + }; + + let mismatch = wrapped(verify(&verifier, &leaf, "api.example.test").unwrap_err()); + assert!(matches!( + pin_rejection(mismatch.as_ref()), + Some(PinRejection::Mismatch(host)) if host == "api.example.test" + )); + + let redirect = wrapped(verify(&verifier, &leaf, "redirect.example.test").unwrap_err()); + assert!(matches!( + pin_rejection(redirect.as_ref()), + Some(PinRejection::HostNotPinned(host)) if host == "redirect.example.test" + )); + + let unrelated = wrapped(RustlsError::General("handshake failed".into())); + assert!(pin_rejection(unrelated.as_ref()).is_none()); + } + + #[test] + fn builds_a_rustls_client_config_with_custom_roots() { + let ca_key = KeyPair::generate().unwrap(); + let mut ca_params = CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let ca = ca_params.self_signed(&ca_key).unwrap(); + let mut roots = RootCertStore::empty(); + roots.add(ca.der().clone()).unwrap(); + + let extension = SpkiPinning::new() + .pin("example.com", EXAMPLE_PIN) + .unwrap() + .root_certificates(roots); + assert_eq!(extension.roots.as_ref().unwrap().len(), 1); + build_tls_config(extension.pins, extension.roots, true).unwrap(); + } + + #[test] + fn reuses_the_tls_config_until_settings_change() { + let extension = SpkiPinning::new().pin("example.com", EXAMPLE_PIN).unwrap(); + extension.tls_config().unwrap(); + let shared = extension.clone(); + assert!(Arc::ptr_eq(&extension.tls, &shared.tls)); + assert!(shared.tls.get().is_some()); + + // a clone changed afterwards gets its own config, without touching the original's + let changed = shared + .pin("example.org", EXAMPLE_PIN) + .unwrap() + .allow_unpinned_hosts(); + assert!(changed.tls.get().is_none()); + assert!(extension.tls.get().is_some()); + changed.tls_config().unwrap(); + } + + #[test] + fn builds_a_rustls_client_config() { + let extension = SpkiPinning::new().pin("example.com", EXAMPLE_PIN).unwrap(); + let config = build_tls_config(extension.pins, None, true).unwrap(); + assert_eq!(config.alpn_protocols.last().unwrap(), b"http/1.1"); + #[cfg(feature = "http2")] + assert_eq!(config.alpn_protocols.first().unwrap(), b"h2"); + } +}