Skip to content

feat(desktop): OS trust store and mTLS client certs for native HTTP - #1225

Merged
giswqs merged 5 commits into
mainfrom
fix/issue-1220-native-http-mtls
Jul 14, 2026
Merged

feat(desktop): OS trust store and mTLS client certs for native HTTP#1225
giswqs merged 5 commits into
mainfrom
fix/issue-1220-native-http-mtls

Conversation

@giswqs

@giswqs giswqs commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

Addresses #1220. The desktop app's native Rust HTTP client trusted only the bundled Mozilla roots and could not present a client certificate, so remote fetches issued from the native process (tile/URL resolution, OGC GetCapabilities, and similar) failed against enterprise endpoints behind a private CA or mutual TLS (mTLS), even though the WebView fetch path succeeded (it uses the OS trust store and an interactive certificate prompt).

What this delivers

  1. OS trust store parity (server verification). guarded_http_client now trusts the OS/system certificate store in addition to the bundled roots (rustls-tls-native-roots), so a server signed by an enterprise CA installed on the machine is accepted with no configuration. The existing SSRF guard (GuardedDnsResolver, redirect re-validation) is unchanged.

  2. Client certificates for mTLS (config-based). When configured via environment variables, the native client presents a client certificate:

    Variable Purpose
    GEOLIBRE_HTTP_CA_CERT PEM bundle of extra CA cert(s) to trust, on top of the OS store (private CA not installed system-wide).
    GEOLIBRE_HTTP_CLIENT_CERT Client cert to present: a .pem (chain + unencrypted PKCS#8 key) or a PKCS#12 .p12/.pfx.
    GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD Passphrase for a PKCS#12 cert (also forces the PKCS#12 path).

    PEM identities use the default rustls backend; PKCS#12 identities (which is what Windows exports and what carries a passphrase) switch that one client to the platform native-tls backend (SChannel / Secure Transport / OpenSSL), which also reads the OS trust store.

Why not the interactive prompt

The reporter's preferred solution is an interactive OS certificate prompt like the WebView shows. That prompt is driven by WebView2/SChannel; reqwest exposes no interactive OS client-cert selection on any backend. True prompt parity would require custom Windows SChannel unsafe code. This PR implements the reporter's documented secondary option (config-based certs) instead, which is the mechanism the native path can actually support. See the issue comment for the full assessment.

Note on dependencies

Enabling the native-tls feature (for PKCS#12 + passphrase) adds a platform TLS dependency: SChannel on Windows and Secure Transport on macOS (both OS-provided), and OpenSSL on Linux. Linux desktop builds therefore link libssl. If you would rather avoid the Linux OpenSSL dependency, I can drop native-tls and support PEM client certs only (users convert .p12 with a one-line openssl command); happy to adjust.

Verification

Cannot exercise real enterprise mTLS here (Linux, no mTLS endpoint), so I verified the exact reqwest plumbing end to end against a local openssl s_server requiring a client certificate:

  • rustls + PEM client cert -> handshake succeeds (200)
  • native-tls + PKCS#12 client cert with passphrase -> handshake succeeds (200)
  • rustls + CA trusted but no client cert -> rejected (confirms mTLS is enforced and the cert is what unlocks it)
  • PKCS#12 with wrong passphrase -> rejected at load

Plus a unit test for the PEM-vs-PKCS#12 classification, and cargo test (16 pass), cargo fmt --check, cargo check, and pre-commit all green. The reporter offered to verify on real Windows enterprise mTLS, which is the remaining validation this can't cover here, so I left the issue open rather than auto-closing.

Docs: added a "Native HTTP trust store and mutual TLS" section to docs/architecture.md.

Summary by CodeRabbit

  • New Features
    • Desktop HTTP requests now trust the operating system certificate store alongside bundled roots, with support for adding extra CA bundles.
    • Optional mutual TLS can be configured via environment variables, supporting PEM and PKCS#12 client certificates.
  • Documentation
    • Expanded security docs with setup details for extra CAs, client certs, optional PKCS#12 passwords, and certificate conversion guidance.
  • Bug Fixes
    • Improved HTTPS fetching and redirect handling with consistent, per-request timeouts.
  • Tests
    • Added tests for client certificate format selection and invalid configuration scenarios.

The desktop app's native Rust HTTP client (guarded_http_client) trusted only
the bundled Mozilla roots and could not present a client certificate, so remote
fetches (tile/URL resolution, OGC GetCapabilities) to enterprise endpoints
behind a private CA or mutual TLS failed, while the WebView fetch path succeeded
via the OS trust store and an interactive cert prompt.

- Trust the OS/system certificate store in addition to the bundled roots
  (rustls-tls-native-roots), so enterprise-CA-signed servers work with no
  configuration.
- Present a client certificate for mutual TLS when configured via environment
  variables: GEOLIBRE_HTTP_CLIENT_CERT (PEM or PKCS#12), an optional
  GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD, and GEOLIBRE_HTTP_CA_CERT for a private CA.
  PEM identities use the default rustls backend; PKCS#12 (with passphrase)
  switches that one client to the platform native-tls backend.

The interactive OS certificate prompt the WebView shows is not reachable from
reqwest, so the native path uses this config-based mechanism instead.

Verified end to end against a local mTLS server: both the PEM (rustls) and
PKCS#12-with-passphrase (native-tls) client-cert paths complete the handshake,
a client without the cert is rejected, and a wrong passphrase fails to load.

Refs #1220
Copilot AI review requested due to automatic review settings July 13, 2026 22:11
@netlify

netlify Bot commented Jul 13, 2026

Copy link
Copy Markdown

Deploy Preview for geolibre-app ready!

Name Link
🔨 Latest commit aebc400
🔍 Latest deploy log https://app.netlify.com/projects/geolibre-app/deploys/6a55a4f39edc7d0008f092f4
😎 Deploy Preview https://deploy-preview-1225--geolibre-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@giswqs, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1bd9ed6f-c608-469f-8569-92d284671920

📥 Commits

Reviewing files that changed from the base of the PR and between ec6877a and aebc400.

📒 Files selected for processing (2)
  • apps/geolibre-desktop/src-tauri/src/lib.rs
  • docs/architecture.md
📝 Walkthrough

Walkthrough

The desktop Rust HTTP client now uses OS certificate roots, accepts extra CA certificates, and supports optional PEM or PKCS#12 client certificates for mTLS. Request handling, TLS dependencies, tests, and security documentation were updated.

Changes

Native TLS and mutual TLS

Layer / File(s) Summary
TLS configuration and guarded client
apps/geolibre-desktop/src-tauri/Cargo.toml, apps/geolibre-desktop/src-tauri/src/lib.rs
Reqwest enables native roots and optional native TLS; the guarded client loads configured CA certificates and PEM or PKCS#12 identities while retaining SSRF protections.
Request integration and validation
apps/geolibre-desktop/src-tauri/src/lib.rs, docs/architecture.md
Fetch and redirect requests reuse the guarded client with request-level timeouts; tests validate certificate selection and configuration errors, and security documentation describes the TLS settings.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

  • opengeos/GeoLibre issue 1220 — The changes implement OS trust-store and configurable mTLS support for native Rust HTTP requests.

Suggested reviewers: dependabot[bot]

Sequence Diagram(s)

sequenceDiagram
  participant Environment
  participant DesktopRustClient
  participant HTTPSService
  Environment->>DesktopRustClient: Configure CA and client certificate paths
  DesktopRustClient->>DesktopRustClient: Apply OS roots, extra CA, and optional identity
  DesktopRustClient->>HTTPSService: Make SSRF-guarded HTTPS request
  HTTPSService-->>DesktopRustClient: Return authenticated HTTP response
Loading

Poem

A rabbit hops through roots of trust,
With certificates in its pack.
PEM or PKCS#12,
It guards the HTTPS track.
TLS blooms beneath the moon—
And mTLS brings answers back.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main desktop HTTP change: OS trust store support and mTLS client certificates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1220-native-http-mtls

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

⚡ Cloudflare Pages preview

Item Value
Preview https://88f6904c.geolibre-preview.pages.dev
Demo app https://88f6904c.geolibre-preview.pages.dev/demo/
Commit bc4620c

Comment on lines 804 to 810
/// Build a blocking HTTP client that enforces the SSRF guard at connect time
/// (via [`GuardedDnsResolver`]) and re-validates redirect hops.
///
/// The client trusts the OS certificate store (via the `rustls-tls-native-roots`
/// feature) so enterprise CAs work, and presents a client certificate for
/// mutual TLS when one is configured (issue #1220).
fn guarded_http_client(timeout: Duration) -> Result<reqwest::blocking::Client, String> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: guarded_http_client is invoked fresh on every call to fetch_url_bytes_blocking/resolve_url_redirect_blocking (i.e. on every native fetch/URL-resolve command), and it now re-reads and re-parses the CA bundle and client certificate (including a PKCS#12 parse when mTLS is configured) from disk each time via extra_ca_certificates()/client_identity(). Since none of this config changes between calls, consider building the client once (e.g. behind a std::sync::OnceLock/OnceCell, or memoized on first use) instead of reconstructing it — including redoing crypto parsing — on every request.

Confidence: medium — not a correctness bug, but adds avoidable I/O and crypto work to a path that's on every OGC/XYZ/add-data fetch.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4b672b1: the guarded client is built once behind a OnceLock, so the CA bundle and client certificate (including the PKCS#12 parse) are read and parsed a single time rather than on every fetch. Per-call deadlines moved to RequestBuilder::timeout().

Comment on lines +772 to +783
fn client_identity() -> Result<Option<ClientIdentity>, String> {
let Some(path) = env::var_os(HTTP_CLIENT_CERT_ENV) else {
return Ok(None);
};
let path = PathBuf::from(path);
let bytes = fs::read(&path).map_err(|error| {
format!(
"Could not read client certificate {}: {error}",
path.display()
)
})?;
let password = env::var(HTTP_CLIENT_CERT_PASSWORD_ENV).ok();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD is set but GEOLIBRE_HTTP_CLIENT_CERT is not, this function returns Ok(None) at the early-return on line 773-775 and the password is silently discarded — no client identity is configured and no error/warning is surfaced. A user who sets only the password env var (typo, or forgets the cert-path var) gets silent failure to establish mTLS with no indication of why, likely surfacing later as an opaque server-side TLS rejection instead of a clear local config error.

Confidence: medium — worth at least a debug log or an explicit error when the password is set without a cert path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4b672b1: client_identity() now returns an explicit error when GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD is set without GEOLIBRE_HTTP_CLIENT_CERT, instead of silently dropping the passphrase. Added a unit test (flags_passphrase_without_certificate_path).

Comment on lines +3075 to +3088
#[test]
fn classifies_client_cert_format() {
use std::path::Path;
// Extension selects PKCS#12, case-insensitively.
assert!(client_cert_is_pkcs12(Path::new("/certs/id.p12"), false));
assert!(client_cert_is_pkcs12(Path::new("/certs/id.PFX"), false));
// PEM (and unrecognised) extensions stay PEM without a passphrase.
assert!(!client_cert_is_pkcs12(Path::new("/certs/id.pem"), false));
assert!(!client_cert_is_pkcs12(Path::new("/certs/id.crt"), false));
assert!(!client_cert_is_pkcs12(Path::new("/certs/id"), false));
// A passphrase forces PKCS#12 even for a PEM-looking or extensionless path.
assert!(client_cert_is_pkcs12(Path::new("/certs/id.pem"), true));
assert!(client_cert_is_pkcs12(Path::new("/certs/id"), true));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few minor points on this change, grouped together:

  • Test coverage (medium confidence): the only new test covers client_cert_is_pkcs12's classification logic. There's no test exercising guarded_http_client() itself to confirm the SSRF guard (GuardedDnsResolver / redirect re-validation) still applies once the builder switches to the native-tls backend for a PKCS#12 identity — that switch is new in this PR, and the guard is a security-critical property. Worth at least a targeted test (or explicit manual verification note) that a disallowed IP is still rejected on the native-tls code path, not just the default rustls path.
  • UX/quality (low confidence): client_cert_is_pkcs12 forces the PKCS#12 path whenever a password is present, even for a .pem-extensioned file. Since this implementation's PEM path requires an unencrypted PKCS8 key, a user who has a .pem with an encrypted key and naturally sets GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD hoping to decrypt it will get a "Could not load PKCS#12 client certificate" parse error instead of a message pointing at the real issue (this is documented behavior, so low severity, but the error message could be clearer for that specific case).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good points. On the SSRF guard: the GuardedDnsResolver and redirect policy are set on the builder before and independently of the TLS-backend choice, so .use_native_tls() (the PKCS#12 path) cannot drop them - I added a comment in build_guarded_http_client making that invariant explicit. I verified the full handshake end to end against a local openssl s_server requiring a client cert (both the rustls/PEM and native-tls/PKCS#12-with-passphrase paths succeed; a client without the cert is rejected), but I skipped committing binary cert fixtures for a backend-swap test since the guard holds by construction. On the encrypted-PEM-with-password case: agreed the error could be clearer; it is documented behavior (PEM path needs an unencrypted PKCS#8 key), and a passphrase routes to the PKCS#12 loader by design, so I left it as-is for now.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed the native HTTP client changes for OS trust-store parity and mTLS client certs (apps/geolibre-desktop/src-tauri/src/lib.rs, Cargo.toml, docs/architecture.md). Overall the design is sound: the SSRF guard (GuardedDnsResolver, redirect re-validation) is untouched and applies at the connector level independent of which TLS backend (rustls vs native-tls) ends up selected, and the PEM-vs-PKCS#12 backend pairing (ClientIdentity::Pemuse_rustls_tls(), ClientIdentity::Pkcs12use_native_tls()) is internally consistent.

Bugs

  • None found with high confidence. The classification logic, error handling, and backend/identity pairing all check out against the reqwest 0.12 API.

Security

  • No SSRF/injection issues — the new env vars (GEOLIBRE_HTTP_CA_CERT, GEOLIBRE_HTTP_CLIENT_CERT, GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD) are operator-set local config, not attacker-influenced input, and the DNS-resolver-based SSRF guard is unaffected by the new code paths. Medium confidence: the guard's persistence across the new native-tls backend switch isn't directly covered by a test — see Quality note below.

Performance

  • Medium confidence: guarded_http_client() is rebuilt from scratch (including re-reading and re-parsing the CA bundle/client cert/PKCS#12 from disk) on every call to fetch_url_bytes_blocking/resolve_url_redirect_blocking, i.e. on every native fetch or URL-resolve command, rather than being cached once. Not a correctness issue, but avoidable I/O and crypto work per request. (Inline comment on lib.rs:804.)

Quality

  • Medium confidence: in client_identity(), setting GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD without also setting GEOLIBRE_HTTP_CLIENT_CERT silently discards the password with no warning — a plausible misconfiguration that fails silently. (Inline comment on lib.rs:772.)
  • Medium confidence: the new unit test only covers client_cert_is_pkcs12's classification logic; there's no test confirming the SSRF guard still holds once the client builder switches to the native-tls backend (new in this PR) for a PKCS#12 identity. (Inline comment on lib.rs:3075.)
  • Low confidence: a password set alongside a .pem-extensioned cert forces the PKCS#12 parse path (by design/documented), which can produce a misleading "Could not load PKCS#12 client certificate" error for someone who has a .pem with an encrypted key. Low severity since documented behavior. (Same inline comment as above.)

CLAUDE.md

  • No violations. This touches only the Tauri Rust backend and docs/architecture.md; no UI strings were added (so i18n rules don't apply), and the CSP tile/style host allowlist is unrelated to this native-process HTTP path. Docs were updated alongside the code as expected.

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src-tauri/src/lib.rs`:
- Around line 804-833: Cache the shared client configuration created by
guarded_http_client behind a OnceLock so extra_ca_certificates and
client_identity are loaded only once. Remove the fixed builder timeout and apply
each caller’s requested deadline with RequestBuilder::timeout() in
fetch_url_bytes_blocking and resolve_url_redirect_blocking, preserving their
distinct timeout values while reusing the cached guarded client.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5c01ae8a-5c03-4250-900a-cc29ce4eb7c2

📥 Commits

Reviewing files that changed from the base of the PR and between 124da49 and 00a1429.

⛔ Files ignored due to path filters (1)
  • apps/geolibre-desktop/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • apps/geolibre-desktop/src-tauri/Cargo.toml
  • apps/geolibre-desktop/src-tauri/src/lib.rs
  • docs/architecture.md

Comment thread apps/geolibre-desktop/src-tauri/src/lib.rs Outdated
…sphrase

Address review feedback on the native mTLS change:

- Build the SSRF-guarded HTTP client once and cache it behind a OnceLock instead
  of rebuilding it (re-reading and re-parsing the CA bundle and client
  certificate from disk) on every native fetch. Callers now set their own
  per-request deadline with RequestBuilder::timeout(), so the shared client keeps
  its connection pool and parsed TLS material across fetch and URL-resolve calls.
- Surface GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD set without GEOLIBRE_HTTP_CLIENT_CERT
  as a clear error rather than silently discarding the passphrase.
- Note in guarded_http_client that the SSRF guard is applied independent of the
  TLS backend, so it holds on the native-tls (PKCS#12) path too.

Adds a unit test for the stray-passphrase guard.

/// Load the mutual-TLS client identity named by [`HTTP_CLIENT_CERT_ENV`], if any.
fn client_identity() -> Result<Option<ClientIdentity>, String> {
let password = env::var(HTTP_CLIENT_CERT_PASSWORD_ENV).ok();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (medium confidence): env::var(...).ok() treats an explicitly-empty-string passphrase the same as "a passphrase is set." Deployment tooling (Docker/K8s env interpolation, .env templating like GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD=${SECRET:-}) commonly produces an empty-string value rather than leaving the var unset when no passphrase is configured. With the current code that:

  • forces client_cert_is_pkcs12 down the PKCS#12 path even for a plain PEM cert, or
  • if GEOLIBRE_HTTP_CLIENT_CERT isn't set either, trips the new "stray passphrase" error (client_cert_password_without_path) even though the user never intentionally set a passphrase.

Consider treating an empty string as "not set":

Suggested change
let password = env::var(HTTP_CLIENT_CERT_PASSWORD_ENV).ok();
let password = env::var(HTTP_CLIENT_CERT_PASSWORD_ENV)
.ok()
.filter(|value| !value.is_empty());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in ec6877a: the passphrase is now read with .filter(|v| !v.is_empty()), so an empty-string GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD (common from ${SECRET:-}-style interpolation) counts as unset and neither forces the PKCS#12 path nor trips the stray-passphrase error.

Comment on lines +831 to +835
fn guarded_http_client() -> Result<reqwest::blocking::Client, String> {
static CLIENT: std::sync::OnceLock<Result<reqwest::blocking::Client, String>> =
std::sync::OnceLock::new();
CLIENT.get_or_init(build_guarded_http_client).clone()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design note (medium confidence): caching the client (and its build error) in a OnceLock for the whole process lifetime means GEOLIBRE_HTTP_CA_CERT/GEOLIBRE_HTTP_CLIENT_CERT/_PASSWORD are effectively read exactly once, on the first fetch_url_bytes/resolve_url_redirect call. That's fine for the documented "static launch-time config" use case, but two consequences worth confirming are intended:

  • A single bad/expired cert on the first call permanently poisons every subsequent fetch for the rest of the process's life (previously each call rebuilt the client, so a transient misconfiguration wasn't "sticky").
  • Any future test that exercises guarded_http_client() with different env-var setups in the same test binary (tests run in one process) will observe whatever got cached by whichever test happened to run first — there's no reset hook, so this is a bit of a landmine for later test authors even though the current tests only touch the pure helper functions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both consequences are intended. The env-driven cert config is static launch-time configuration, so reading it once and caching (including a load error) is acceptable - a bad cert means the user fixes the config and relaunches, same as any other launch-time setting. On the test landmine: noted; the current tests only exercise the pure helpers (client_cert_is_pkcs12, client_cert_password_without_path) and never call guarded_http_client(), precisely to avoid depending on process-global cached state. If a future test needs to drive the cached client under different env, it should run as its own test binary or use a serial/reset harness.

const HTTP_CLIENT_CERT_ENV: &str = "GEOLIBRE_HTTP_CLIENT_CERT";
/// Passphrase for a PKCS#12 client certificate. Its presence also forces the
/// PKCS#12 code path for a client cert that lacks a recognised extension.
const HTTP_CLIENT_CERT_PASSWORD_ENV: &str = "GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security note (low confidence): the PKCS#12 passphrase is read from a plaintext environment variable. On Linux/macOS that's readable by other processes running as the same user (e.g. via /proc/<pid>/environ) and may end up in process-manager logs or crash dumps more readily than a file. Not a new class of risk for this codebase, but since this is a fresh secret-handling surface it may be worth supporting a _FILE-suffixed alternative (read the passphrase from a file path) the way many tools do, so the secret isn't sitting directly in the environment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point on the fresh secret surface. For this PR I kept the documented env-var mechanism (the approved scope), which is the same channel already used for other config here. A GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD_FILE variant (read the passphrase from a file, Docker-secrets style) is a sensible follow-up and easy to add on top of the current client_identity() - happy to do it in a follow-up if the maintainer wants it, keeping this PR focused.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Overview: Adds OS trust-store parity (rustls-tls-native-roots) and optional mTLS client certificates (PEM via rustls, PKCS#12 via native-tls) to the desktop app's native guarded_http_client, plus caches that client in a OnceLock instead of rebuilding it per request. SSRF guard (GuardedDnsResolver, redirect re-validation) is unchanged and still applied on both TLS backends.

Bugs

  • Medium confidence: client_identity() reads GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD with env::var(...).ok(), so an explicitly empty-string value (common with Docker/K8s env templating for "unset" secrets) is treated the same as "a passphrase is set." This forces the PKCS#12 code path even for a plain PEM cert, or trips the new "stray passphrase" error when no cert path is set either, despite the user never intending to configure a passphrase. (inline comment, with suggested fix)

Security

  • Low confidence: The mTLS passphrase is passed via a plaintext environment variable, which other processes owned by the same OS user can typically inspect through standard process-introspection mechanisms on Linux/macOS. Not new to this codebase's patterns, but worth considering a file-based alternative (_FILE suffix convention) for this new secret-handling surface. (inline comment)

Performance

  • No issues found — the OnceLock caching in guarded_http_client is a genuine improvement (avoids rebuilding the client, re-reading cert files, and re-parsing certs on every fetch/redirect-resolve call).

Quality

  • Medium confidence: Caching the client (and any build error) in a process-lifetime OnceLock means the mTLS/CA env vars are read exactly once. A bad cert on the first call now permanently poisons every later fetch for the process's life (previously each call rebuilt the client), and there's no reset hook for future tests that might want to exercise different env-var configurations in-process. Likely fine given these are launch-time config, but worth confirming that's the intended lifecycle. (inline comment)
  • Low confidence (nit, not inline): client_cert_password_without_path(has_cert_path, has_password) is only ever called from production code with has_cert_path hardcoded to false; the two-arg generality is exercised only by unit tests. Consider simplifying, or note why the fuller signature is kept.
  • Low confidence (nit, not inline): Enabling native-tls always statically links OpenSSL on Linux desktop builds, even when mTLS/PKCS#12 is never configured. The PR description already flags this tradeoff and offers a PEM-only alternative — worth a maintainer decision before merge, but CI already installs libssl-dev (pre-existing), so it shouldn't break CI as-is.

CLAUDE.md

  • No violations found. Docs updated in docs/architecture.md (a referenced doc), unit tests added alongside existing mod tests, and the change doesn't touch i18n strings, the Whitebox menu catalog, or Tauri CSP tile/style hosts — none of which apply here.

Also reviewed and found correct: root-cert/identity ordering relative to use_rustls_tls()/use_native_tls() backend selection, per-request .timeout() calls on both guarded_http_client() call sites (no unbounded-request regression from removing the client-level .timeout()), and the SSRF DNS resolver/redirect policy being backend-independent.

Env interpolation in Docker/K8s/.env tooling (e.g. PASSWORD=${SECRET:-})
commonly yields an empty string rather than leaving the variable unset. An empty
GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD now counts as no passphrase, so it neither
forces the PKCS#12 code path for a PEM certificate nor trips the
stray-passphrase error.

/// Load extra CA certificate(s) named by [`HTTP_CA_CERT_ENV`], if any.
fn extra_ca_certificates() -> Result<Vec<reqwest::Certificate>, String> {
let Some(path) = env::var_os(HTTP_CA_CERT_ENV) else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (high confidence): env::var_os returns Some("") when GEOLIBRE_HTTP_CA_CERT is set but empty — exactly the Docker/K8s/.env templating scenario (CA_CERT=${SECRET:-}) that the very next commit in this PR fixes for GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD. An empty path here isn't filtered, so it falls through to fs::read(""), which fails, and build_guarded_http_client()'s error propagates up.

Because guarded_http_client() caches its Result in a OnceLock (line 838-840), that failure is permanent for the process lifetime — it breaks every native fetch (fetch_url_bytes, resolve_url_redirect) app-wide, not just requests that need the extra CA. A stray empty env var turns into a total native-fetch outage rather than a no-op.

Suggested change
let Some(path) = env::var_os(HTTP_CA_CERT_ENV) else {
let Some(path) = env::var_os(HTTP_CA_CERT_ENV).filter(|value| !value.is_empty()) else {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d56b453: extra_ca_certificates() now uses env::var_os(HTTP_CA_CERT_ENV).filter(|v| !v.is_empty()), so a set-but-empty CA path is treated as unset instead of failing on fs::read("") and poisoning the cached client.

let password = env::var(HTTP_CLIENT_CERT_PASSWORD_ENV)
.ok()
.filter(|value| !value.is_empty());
let Some(path) = env::var_os(HTTP_CLIENT_CERT_ENV) else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (high confidence): same empty-string gap as extra_ca_certificates() (see other comment), here for GEOLIBRE_HTTP_CLIENT_CERT. env::var_os treats a set-but-empty value as Some(""), so:

  1. It bypasses the client_cert_password_without_path check right below (since has_cert_path becomes effectively "true"), silently defeating the misconfiguration guard this PR just added for the analogous case.
  2. It then fails later at fs::read with an opaque "No such file or directory" on an empty path, instead of the clear "cert path is not set" message.
  3. Per the sibling comment, that error is cached forever in the OnceLock-backed client, so this permanently breaks all native fetches, not just the mTLS ones.
Suggested change
let Some(path) = env::var_os(HTTP_CLIENT_CERT_ENV) else {
let Some(path) = env::var_os(HTTP_CLIENT_CERT_ENV).filter(|value| !value.is_empty()) else {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d56b453 with the same .filter(|v| !v.is_empty()) on HTTP_CLIENT_CERT_ENV, so an empty cert path no longer bypasses the stray-passphrase guard or fails later on an empty path.

Comment on lines +837 to +857
fn guarded_http_client() -> Result<reqwest::blocking::Client, String> {
static CLIENT: std::sync::OnceLock<Result<reqwest::blocking::Client, String>> =
std::sync::OnceLock::new();
CLIENT.get_or_init(build_guarded_http_client).clone()
}

fn build_guarded_http_client() -> Result<reqwest::blocking::Client, String> {
// The SSRF guard (GuardedDnsResolver + redirect re-validation) is applied
// here, independent of the TLS backend chosen below, so it holds on both the
// rustls and native-tls paths.
let mut builder = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(REMOTE_TILE_CONNECT_TIMEOUT_SECS))
.redirect(guarded_redirect_policy())
.dns_resolver(std::sync::Arc::new(GuardedDnsResolver))
.user_agent("GeoLibre Desktop")
.user_agent("GeoLibre Desktop");

for certificate in extra_ca_certificates()? {
builder = builder.add_root_certificate(certificate);
}

builder = match client_identity()? {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security (medium confidence): guarded_http_client() is a single process-wide singleton used by both fetch_url_bytes and resolve_url_redirect for any URL the app fetches natively — arbitrary tile/style/OGC hosts a user adds, not only the intended mTLS endpoint. Once GEOLIBRE_HTTP_CLIENT_CERT is configured, this same client (with the identity baked in at build time) is reused for every native request, so it will present the configured client certificate during the TLS handshake to any HTTPS server that issues a CertificateRequest — including unrelated public tile/style hosts a user points the app at, not just the enterprise host the cert was meant for.

TLS client certs are only sent when the peer asks for one, so this isn't an unconditional leak, but it does mean an enterprise client identity configured for one host can be exposed to any other HTTPS server the app talks to that happens to request a client cert. Worth at least calling out in the docs, or consider whether the identity can be scoped to the configured host.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Documented in d56b453: the docs now note that the configured client certificate is held on the shared native client and is presented to any HTTPS server that requests one (not only the enterprise endpoint), so it should only be configured when the app's hosts are trusted. Per-host scoping (an allowlist of hosts the identity may be sent to) is a reasonable follow-up if needed, but that is a larger change than this PR's scope.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed the native mTLS/OS-trust-store change (guarded_http_client + client_identity/extra_ca_certificates in apps/geolibre-desktop/src-tauri/src/lib.rs, plus the Cargo.toml/Cargo.lock/docs/architecture.md changes). Inline comments posted on the specific lines; summary below.

Bugs

  • HighGEOLIBRE_HTTP_CA_CERT set to an empty string (e.g. via CA_CERT=${SECRET:-} templating) is not filtered like the passphrase is, so it falls through to fs::read("") and fails. Because guarded_http_client() caches its Result in a OnceLock, this permanently breaks every native fetch for the process lifetime, not just CA-dependent ones. (extra_ca_certificates, line 768)
  • High — Same gap for GEOLIBRE_HTTP_CLIENT_CERT: an empty-but-set value bypasses the new client_cert_password_without_path misconfiguration guard and fails later with an opaque "No such file or directory" instead of a clear error, again permanently poisoning the cached client. This is the same class of bug the PR's last commit just fixed for the passphrase — worth applying the identical .filter(|v| !v.is_empty()) treatment here. (client_identity, line 787)

Security

  • Medium — The SSRF-guarded HTTP client is a single process-wide singleton reused for all native fetches (fetch_url_bytes, resolve_url_redirect), not just requests to the configured mTLS host. Once a client certificate is configured, it will be presented to any HTTPS server that requests one during the TLS handshake — including unrelated public tile/style hosts the app talks to — which could expose an enterprise client identity beyond its intended endpoint. Not an unconditional leak (servers must request a client cert), but worth documenting or scoping. (build_guarded_http_client, lines 837-863)

Performance

  • No issues found. Caching the client behind a OnceLock and moving the timeout to per-request RequestBuilder::timeout() is a sound fix for the previous "rebuild + reparse cert on every fetch" pattern, and preserves prior timeout behavior for both callers.

Quality

  • Low — Neither of the two Bugs above has unit test coverage, even though the analogous empty-passphrase case does (flags_passphrase_without_certificate_path, etc.). Adding empty-string cases for GEOLIBRE_HTTP_CA_CERT/GEOLIBRE_HTTP_CLIENT_CERT alongside the existing password test would have caught the issue above.
  • The added native-tls/OpenSSL dependency on Linux is a real increase in dependency surface, but it's already transparently disclosed and discussed in the PR description, so not flagging it as a hidden issue.

CLAUDE.md

  • No violations found. New env-var behavior is documented in docs/architecture.md per the "reference docs" convention, and the change is scoped to src-tauri with no impact on i18n, plugin, or Processing-menu conventions.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/geolibre-desktop/src-tauri/src/lib.rs (2)

3115-3137: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a regression test for empty-passphrase normalization.

The new .filter(|value| !value.is_empty()) behavior is not exercised by the added tests, which only test downstream boolean helpers. Add a focused test for the empty environment value, or extract password normalization into a pure helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/geolibre-desktop/src-tauri/src/lib.rs` around lines 3115 - 3137, The
tests cover downstream helpers but not the new empty-passphrase normalization.
Add a focused regression test around the environment-password normalization
flow, such as the relevant configuration-loading function, asserting that an
empty environment value is treated as absent; alternatively extract the
normalization into a pure helper and test that helper directly.

803-819: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

PKCS#12 path drops the bundled Mozilla roots

The native-tls branch still gets the OS store and GEOLIBRE_HTTP_CA_CERT, but it no longer uses the bundled Mozilla roots documented for guarded_http_client. PKCS#12 users can see different server certificate acceptance than the PEM/rustls path; keep the same CA set on both paths or avoid switching backends.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/geolibre-desktop/src-tauri/src/lib.rs` around lines 803 - 819, Update
the PKCS#12 handling in the client-identity flow around client_cert_is_pkcs12
and reqwest::Identity::from_pkcs12_der so the native-tls path preserves the
bundled Mozilla roots used by guarded_http_client, while retaining the OS store
and GEOLIBRE_HTTP_CA_CERT behavior. Ensure PKCS#12 and PEM/rustls paths use the
same effective CA set, or avoid the backend switch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src-tauri/src/lib.rs`:
- Around line 780-786: Update the password retrieval near
HTTP_CLIENT_CERT_PASSWORD_ENV to distinguish env::VarError::NotUnicode from
VarError::NotPresent: treat only missing or empty values as unset, and return a
configuration error for non-UTF-8 values before certificate-path selection and
stray-passphrase validation proceed.

---

Outside diff comments:
In `@apps/geolibre-desktop/src-tauri/src/lib.rs`:
- Around line 3115-3137: The tests cover downstream helpers but not the new
empty-passphrase normalization. Add a focused regression test around the
environment-password normalization flow, such as the relevant
configuration-loading function, asserting that an empty environment value is
treated as absent; alternatively extract the normalization into a pure helper
and test that helper directly.
- Around line 803-819: Update the PKCS#12 handling in the client-identity flow
around client_cert_is_pkcs12 and reqwest::Identity::from_pkcs12_der so the
native-tls path preserves the bundled Mozilla roots used by guarded_http_client,
while retaining the OS store and GEOLIBRE_HTTP_CA_CERT behavior. Ensure PKCS#12
and PEM/rustls paths use the same effective CA set, or avoid the backend switch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a7876715-7386-4938-8355-4e02b7d2fbab

📥 Commits

Reviewing files that changed from the base of the PR and between 4b672b1 and ec6877a.

📒 Files selected for processing (1)
  • apps/geolibre-desktop/src-tauri/src/lib.rs

Comment thread apps/geolibre-desktop/src-tauri/src/lib.rs Outdated
…sphrase

More review follow-ups on the native mTLS config:

- Filter set-but-empty GEOLIBRE_HTTP_CA_CERT and GEOLIBRE_HTTP_CLIENT_CERT the
  same way as the passphrase, so ${SECRET:-}-style empty values are treated as
  unset instead of read as the path "" (which would fail on fs::read and, since
  the guarded client is cached, permanently break every native fetch).
- Surface a non-UTF-8 GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD as a clear error rather
  than silently dropping it, since the PKCS#12 loader takes a &str passphrase.
- Document that the configured client certificate is held on the shared native
  HTTP client and is presented to any host that requests one during the TLS
  handshake, so it should only be configured when the app's hosts are trusted.
Comment on lines +847 to +851
fn guarded_http_client() -> Result<reqwest::blocking::Client, String> {
static CLIENT: std::sync::OnceLock<Result<reqwest::blocking::Client, String>> =
std::sync::OnceLock::new();
CLIENT.get_or_init(build_guarded_http_client).clone()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robustness (medium confidence): guarded_http_client() caches an Err in the OnceLock forever once build_guarded_http_client() fails. Since this same client backs plain (non-mTLS) tile fetches and URL resolution (fetch_url_bytes_blocking, resolve_url_redirect_blocking), a misconfiguration that has nothing to do with a user's intent to use mTLS — e.g. a stray non-UTF‑8 byte in GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD left over in a shell profile, with GEOLIBRE_HTTP_CLIENT_CERT never set — will now permanently break all native tile/URL fetching for the entire process lifetime (see the NotUnicode branch in client_identity() around line 791, which errors unconditionally before even checking whether a cert path was configured).

Previously this failure mode didn't exist at all, since there was no cert/CA config to misconfigure. Given the cache-forever design (intentional per the "perf" commit), it might be worth at least logging clearly which env var caused the failure so it's diagnosable, or considering whether an env-var-driven mTLS misconfiguration should really take down basic tile loading rather than degrading gracefully (e.g. falling back to no client cert with a warning).

Comment on lines +42 to +45
# rustls-tls stays the default backend for every request. rustls-tls-native-roots
# also trusts the OS/system certificate store (enterprise CAs), and native-tls is
# used only on the opt-in mTLS path for PKCS#12 client certificates (issue #1220).
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "rustls-tls-native-roots", "native-tls"] }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (low-medium confidence, already flagged by the author in the PR description): native-tls and rustls-tls-native-roots are enabled unconditionally, so every desktop build — including Linux, where native-tls pulls in openssl/openssl-sys and links libssl — now carries this dependency even for the overwhelming majority of users who will never set GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD to use a PKCS#12 identity. That's a real increase in build complexity (system OpenSSL dependency on Linux) and attack surface (an extra TLS stack) for a feature most installs won't use. Worth a deliberate call on whether to gate native-tls behind a Cargo feature flag (off by default) or accept the always-on cost, rather than defaulting it on silently.

Comment on lines +853 to 878
fn build_guarded_http_client() -> Result<reqwest::blocking::Client, String> {
// The SSRF guard (GuardedDnsResolver + redirect re-validation) is applied
// here, independent of the TLS backend chosen below, so it holds on both the
// rustls and native-tls paths.
let mut builder = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(REMOTE_TILE_CONNECT_TIMEOUT_SECS))
.redirect(guarded_redirect_policy())
.dns_resolver(std::sync::Arc::new(GuardedDnsResolver))
.user_agent("GeoLibre Desktop")
.user_agent("GeoLibre Desktop");

for certificate in extra_ca_certificates()? {
builder = builder.add_root_certificate(certificate);
}

builder = match client_identity()? {
// PKCS#12 identities are only understood by native-tls, which also reads
// the OS trust store on every platform; switch this one client over.
Some(ClientIdentity::Pkcs12(identity)) => builder.use_native_tls().identity(identity),
Some(ClientIdentity::Pem(identity)) => builder.use_rustls_tls().identity(identity),
None => builder.use_rustls_tls(),
};

builder
.build()
.map_err(|error| format!("Could not create HTTP client: {error}"))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nits (low confidence, non-blocking):

  1. No default request timeout on the shared client. Previously guarded_http_client(timeout) baked a total-request timeout into the client itself. Now build_guarded_http_client() only sets connect_timeout; the two current call sites (fetch_url_bytes_blocking, resolve_url_redirect_blocking) correctly chain .timeout(...) on the RequestBuilder, but there's no client-level fallback. A future caller of guarded_http_client() that forgets to set a per-request timeout would have no read/total timeout at all (only the connect phase is bounded), and could hang indefinitely on a slow/stalled server.

  2. Test coverage gap. classifies_client_cert_format / flags_passphrase_without_certificate_path cover the extracted pure predicates well, but the actual env-var plumbing in client_identity()/extra_ca_certificates() — empty-string-treated-as-unset, and the non-UTF-8 password error path — has no test exercising the real env::var/env::var_os reads (understandably, since mutating process env in parallel tests is racy). Consider at least a #[test] gated to run serially, or an internal helper that takes the env lookups as parameters so it's testable without touching real process env.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed the native Rust HTTP client changes (OS trust store + mTLS client certs) in apps/geolibre-desktop/src-tauri/src/lib.rs, Cargo.toml/Cargo.lock, and docs/architecture.md. Overall the SSRF guard (GuardedDnsResolver, redirect re-validation) is correctly preserved across both TLS backends, the env-var parsing (empty-string-as-unset, stray-passphrase, non-UTF-8 password) is careful and well-documented, and the client-caching refactor is sound (OnceLock + Arc-backed Client::clone()). No secrets, injection, or SSRF regressions found.

Bugs

  • None found with high confidence. See Quality below for a caching interaction worth a second look.

Security

  • No issues found. The SSRF guard (is_disallowed_ip, GuardedDnsResolver, guarded redirect policy) is applied identically regardless of TLS backend, and the code correctly notes/documents (in docs/architecture.md) that a configured client certificate is presented to any host requesting one during a native fetch — this is disclosed rather than hidden, which is the right call.

Performance

  • No issues. Caching the client behind OnceLock instead of rebuilding/reparsing certs per request is a clear improvement, and moving the timeout to per-request RequestBuilder::timeout() is correct (both call sites set it).

Quality

  • (medium confidence) guarded_http_client() permanently caches a build failure. Because the same shared client backs plain, non-mTLS tile/URL fetches, a misconfigured mTLS-only env var (e.g., a stray non-UTF-8 byte in GEOLIBRE_HTTP_CLIENT_CERT_PASSWORD with no cert path set) now breaks all native HTTP fetching for the process lifetime, not just mTLS use — a new failure mode that didn't exist before this PR. (inline comment on lib.rs:847-851)
  • (low-medium confidence, already raised by the author in the PR description) native-tls is enabled unconditionally in Cargo.toml, so Linux desktop builds now link OpenSSL even for the vast majority of users who never configure a PKCS#12 client cert. Worth a deliberate decision on gating it behind an opt-in feature vs. accepting the always-on cost. (inline comment on Cargo.toml:42-45)
  • (low confidence) Removing the client-level default timeout in favor of per-request .timeout() calls is fine today (both call sites set it) but leaves no fallback if a future caller forgets — worth a comment/guard rail. (inline comment on lib.rs:853-878)
  • (low confidence) The env-var-reading logic in client_identity()/extra_ca_certificates() (empty-string filtering, non-UTF-8 handling) isn't directly unit-tested — only the extracted pure predicates (client_cert_is_pkcs12, client_cert_password_without_path) are. Understandable given the raciness of mutating process env in tests, but worth considering a parametrized/serial test. (same inline comment as above)

CLAUDE.md

  • No violations found. This change doesn't touch translated UI strings, the Processing menu catalog, or Tauri CSP tile/style host allowlists, so none of the repo-specific conventions in CLAUDE.md apply here.

Comment on lines +847 to +851
fn guarded_http_client() -> Result<reqwest::blocking::Client, String> {
static CLIENT: std::sync::OnceLock<Result<reqwest::blocking::Client, String>> =
std::sync::OnceLock::new();
CLIENT.get_or_init(build_guarded_http_client).clone()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bugs / Robustness (medium confidence): Caching a failed build in the OnceLock means a single bad mTLS/CA env-var value (wrong passphrase, corrupt PEM, stale path) permanently breaks every native fetch for the life of the process — not just requests that need the client cert. Tile loading, URL resolution, and OGC GetCapabilities calls that have nothing to do with mTLS will all fail with a certificate-loading error message once this is cached, until the app is restarted with a fixed/unset env var.

Before this PR there was no cert config, so this single point of failure is new. Consider building the base client independently of extra_ca_certificates()/client_identity(), and only failing (or falling back) the requests that actually need the extra trust/identity — or at minimum surface a one-time startup diagnostic so a misconfigured cert doesn't read as "tile loading is broken."

Comment on lines +853 to +861
fn build_guarded_http_client() -> Result<reqwest::blocking::Client, String> {
// The SSRF guard (GuardedDnsResolver + redirect re-validation) is applied
// here, independent of the TLS backend chosen below, so it holds on both the
// rustls and native-tls paths.
let mut builder = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(REMOTE_TILE_CONNECT_TIMEOUT_SECS))
.redirect(guarded_redirect_policy())
.dns_resolver(std::sync::Arc::new(GuardedDnsResolver))
.user_agent("GeoLibre Desktop")
.user_agent("GeoLibre Desktop");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (low-medium confidence): The client builder no longer sets a default .timeout(...); a request timeout is now opt-in per call site via RequestBuilder::timeout(). Both current callers (fetch_url_bytes_blocking, resolve_url_redirect_blocking) do set it, so there's no active bug, but the shared client has no backstop timeout anymore — a future caller that forgets .timeout() gets unbounded read/write time (only connect_timeout still applies), risking an indefinite hang instead of a clear timeout error. Worth a comment at the call sites, or a fallback default on the builder, so this can't be silently reintroduced.

Comment thread docs/architecture.md
Comment on lines +134 to +139
The configured client certificate is held on the shared native HTTP client and
is therefore **presented to any HTTPS server that requests one** during a native
fetch (tile, style, and OGC hosts a project points at), not only the endpoint the
certificate was issued for. TLS sends a client certificate only when the server
asks for one, so this is not an unconditional disclosure, but configure a client
certificate only when the hosts the app talks to are trusted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security (medium confidence, already partly disclosed here): Because the client certificate lives on the single shared guarded_http_client, it's presented to any HTTPS host that asks for one during a native fetch — including arbitrary tile/style/OGC URLs a user (or a shared .geolibre.json project, or a plugin) can point the app at. This is a confused-deputy-style risk: once mTLS is configured for a trusted enterprise endpoint, an attacker-controlled tile URL added to a project can also elicit the org's client certificate during its own TLS handshake, since nothing scopes cert presentation to a specific host/host-pattern.

The doc is honest about this, which is good, but given GeoLibre's data flow explicitly allows arbitrary remote tile/service URLs (see CLAUDE.md's Add Data flow), it may be worth scoping the client identity to an allow-listed set of hosts rather than presenting it on every native request.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed the diff (Cargo.lock/Cargo.toml, src-tauri/src/lib.rs, docs/architecture.md) adding OS trust-store parity and config-based mTLS client certs to the native HTTP client, plus caching that client behind a OnceLock.

Bugs

  • None found that would cause incorrect results on the happy path. The PEM/PKCS#12 classification, empty-env-var handling, and stray-passphrase guard are all covered by unit tests and look correct.

Security

  • Medium confidence: The configured mTLS client certificate lives on the single shared guarded_http_client and is presented to any HTTPS host that asks for one during a native fetch — including arbitrary tile/style/OGC URLs a project file or plugin can point the app at, not just the intended enterprise endpoint. This is disclosed in the new docs/architecture.md section, but it's a real confused-deputy-style exposure worth a design discussion (e.g. scoping the identity to an allow-listed host set) given GeoLibre's data flow allows arbitrary remote tile/service URLs.

Performance

  • No concerns. Moving the client build behind a cached OnceLock (instead of rebuilding/re-parsing cert material per fetch) is a clear improvement, and the SSRF guard (GuardedDnsResolver, redirect re-validation) is preserved and applied independent of TLS backend.

Quality

  • Medium-high confidence: Because build failures are cached in the OnceLock (guarded_http_client, lib.rs:847-851), a single bad GEOLIBRE_HTTP_CA_CERT/GEOLIBRE_HTTP_CLIENT_CERT/..._PASSWORD value permanently breaks all native fetches (tile loading, URL resolution, OGC capabilities) for the process lifetime, not just mTLS-specific requests — a much larger blast radius than before this PR, and one that could confuse users seeing certificate errors on unrelated actions.
  • Low-medium confidence: The client builder no longer sets a default .timeout(...); both current call sites correctly set a per-request timeout via RequestBuilder::timeout(), but the shared client itself has no backstop timeout anymore, so a future caller that forgets it would hang indefinitely instead of failing with a clear timeout.
  • Minor: the PKCS#12 passphrase is kept in a plain String in memory without zeroization — low severity for a local desktop config value, not blocking.

CLAUDE.md

  • No violations noted. Docs were updated (docs/architecture.md), the change is scoped to apps/geolibre-desktop/src-tauri, and cargo fmt/cargo check/tests are reported green per the PR description; branch/PR workflow (not committed to main) is followed correctly.

@giswqs
giswqs merged commit 1c92657 into main Jul 14, 2026
15 checks passed
@giswqs
giswqs deleted the fix/issue-1220-native-http-mtls branch July 14, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants