Skip to main content

rings_node/onion/https/
mod.rs

1//! HTTPS onion-exit request/response adapter.
2//!
3//! This protocol is intentionally application-layer HTTPS. Clients can send an HTTPS request
4//! description over the route-aware onion circuit, the exit performs the request, and the response
5//! is sent back over the circuit return path.
6//!
7//! A browser page exit is constrained by the host browser's `fetch` capability: CORS, forbidden
8//! headers, credentials policy, and extension host permissions still apply. A full arbitrary HTTPS
9//! exit must run in a browser-extension or native context that grants those fetch permissions.
10
11#[cfg(any(test, rings_browser))]
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::sync::Mutex;
15
16use bytes::Bytes;
17#[cfg(any(test, rings_browser))]
18use futures::channel::oneshot;
19use rings_core::dht::Did;
20use rings_core::session::SessionSk;
21use serde::Deserialize;
22use serde::Serialize;
23
24#[cfg(rings_browser)]
25use self::browser::execute_https_request;
26#[cfg(test)]
27use self::limits::checked_status_code;
28use self::limits::https_response_body_limit;
29use self::limits::usize_to_u64;
30#[cfg(rings_native)]
31use self::native::execute_https_request;
32#[cfg(all(test, rings_native))]
33use self::native::native_fetch_with_timeout;
34#[cfg(all(test, rings_native))]
35use self::native::select_native_https_egress;
36#[cfg(all(test, rings_native))]
37use self::native::NativeHttpsEgress;
38#[cfg(any(test, rings_browser))]
39use self::pending::PendingOnionHttpsRequest;
40use crate::error::Error;
41use crate::error::Result;
42use crate::extension::ext::Scope;
43use crate::onion::circuit::send_backward;
44#[cfg(any(test, rings_browser))]
45use crate::onion::circuit::OnionAuthenticatedPayload;
46use crate::onion::circuit::OnionBackwardPath;
47use crate::onion::circuit::OnionBackwardSequence;
48use crate::onion::circuit::OnionCircuitExitFrame;
49#[cfg(rings_browser)]
50use crate::onion::circuit::OnionCircuitHandler;
51use crate::onion::circuit::OnionCircuitId;
52use crate::onion::circuit::OnionCircuitPayload;
53use crate::onion::circuit::OnionForwardNonce;
54use crate::onion::circuit::OnionForwardSequence;
55use crate::onion::circuit::OnionLinkSender;
56#[cfg(any(test, rings_browser))]
57use crate::onion::circuit::OnionReturnId;
58use crate::onion::exit_accounting::OnionExitAccounting;
59use crate::onion::exit_accounting::OnionExitLease;
60use crate::onion::proxy::OnionProxyTarget;
61use crate::onion::proxy::ONION_PROXY_HTTPS_SERVICE;
62use crate::onion::replay::OnionForwardReplayKey;
63use crate::onion::replay::OnionForwardReplayPartitions;
64use crate::onion::replay::ReplayAdmission;
65#[cfg(any(test, rings_browser))]
66use crate::onion::OnionExitDescriptor;
67use crate::onion::OnionExitFailure;
68use crate::onion::OnionExitPolicy;
69use crate::onion::OnionExitTarget;
70use crate::onion::OnionRouteError;
71use crate::sync_lock::lock;
72
73const DEFAULT_HTTPS_RESPONSE_BODY_LIMIT_BYTES: u64 = 8 * 1024 * 1024;
74
75/// One HTTPS request executed by an HTTPS exit.
76#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
77pub struct OnionHttpsRequest {
78    /// Target authority (`host:port`).
79    pub target: String,
80    /// HTTP method.
81    pub method: String,
82    /// Path and query.
83    pub path: String,
84    /// Request headers.
85    pub headers: Vec<(String, String)>,
86    /// Request body bytes.
87    pub body: Vec<u8>,
88}
89
90/// One HTTPS response returned by an HTTPS exit.
91#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
92pub struct OnionHttpsResponse {
93    /// HTTP status code.
94    pub status: u16,
95    /// Response headers.
96    pub headers: Vec<(String, String)>,
97    /// Response body bytes.
98    pub body: Vec<u8>,
99}
100
101#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub(crate) enum OnionHttpsPayload {
103    Request(OnionHttpsRequest),
104    Response(OnionHttpsResponse),
105    Error(OnionExitFailure),
106}
107
108pub(crate) fn encode_https_payload(payload: OnionHttpsPayload) -> Result<OnionCircuitPayload> {
109    rings_codec::serialize(&payload)
110        .map(|body| {
111            OnionCircuitPayload::new(crate::onion::OnionServiceName::https(), Bytes::from(body))
112        })
113        .map_err(|_| Error::EncodeError)
114}
115
116fn decode_https_payload(payload: OnionCircuitPayload) -> Result<Option<OnionHttpsPayload>> {
117    if !payload.matches_service(ONION_PROXY_HTTPS_SERVICE) {
118        return Ok(None);
119    }
120    rings_codec::deserialize(payload.body.as_ref())
121        .map(Some)
122        .map_err(|_| Error::DecodeError)
123}
124
125/// JS-facing request fields for one HTTPS proxy request.
126#[cfg(any(test, rings_browser))]
127#[cfg_attr(test, derive(Default))]
128#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
129pub struct OnionHttpsClientRequest {
130    /// HTTP method. Defaults to `GET`.
131    #[serde(default = "default_method")]
132    pub method: String,
133    /// Optional path and query override. Defaults to the request URL path, then `/`.
134    #[serde(default)]
135    pub path: Option<String>,
136    /// Request headers.
137    #[serde(default)]
138    pub headers: Vec<(String, String)>,
139    /// Request body bytes.
140    #[serde(default)]
141    pub body: Vec<u8>,
142}
143
144/// JS-facing response fields returned from one HTTPS proxy request.
145#[cfg(any(test, rings_browser))]
146#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
147pub struct OnionHttpsClientResponse {
148    /// HTTP status code.
149    pub status: u16,
150    /// Response headers.
151    pub headers: Vec<(String, String)>,
152    /// Response body bytes.
153    pub body: Vec<u8>,
154}
155
156/// Shared runtime for the local HTTPS proxy protocol.
157pub(crate) struct OnionHttpsRuntime {
158    #[cfg(any(test, rings_browser))]
159    pending: Mutex<HashMap<OnionCircuitId, PendingRequest>>,
160    exit_policy: Mutex<Option<OnionExitPolicy>>,
161    forward_replays: Mutex<OnionForwardReplayPartitions>,
162    accounting: OnionExitAccounting,
163    link_sender: OnionLinkSender,
164    #[cfg(rings_native)]
165    native_proxy: Mutex<Option<String>>,
166}
167
168impl Default for OnionHttpsRuntime {
169    fn default() -> Self {
170        Self::with_resources(OnionExitAccounting::default(), OnionLinkSender::default())
171    }
172}
173
174#[cfg(any(test, rings_browser))]
175struct PendingRequest {
176    expected_return_peer: Did,
177    expected_exit: OnionExitDescriptor,
178    return_id: OnionReturnId,
179    sender: oneshot::Sender<std::result::Result<OnionHttpsClientResponse, Error>>,
180}
181
182impl OnionHttpsRuntime {
183    /// Create an empty runtime.
184    #[cfg(any(test, rings_browser))]
185    pub(crate) fn new() -> Self {
186        Self::default()
187    }
188
189    /// Create a runtime sharing node-wide accounting and link-traffic effect capabilities.
190    pub(crate) fn with_resources(
191        accounting: OnionExitAccounting,
192        link_sender: OnionLinkSender,
193    ) -> Self {
194        Self {
195            #[cfg(any(test, rings_browser))]
196            pending: Mutex::new(HashMap::new()),
197            exit_policy: Mutex::new(None),
198            forward_replays: Mutex::new(OnionForwardReplayPartitions::default()),
199            accounting,
200            link_sender,
201            #[cfg(rings_native)]
202            native_proxy: Mutex::new(None),
203        }
204    }
205
206    #[cfg(rings_browser)]
207    pub(crate) fn link_sender(&self) -> OnionLinkSender {
208        self.link_sender.clone()
209    }
210
211    #[cfg(rings_native)]
212    pub(crate) fn set_native_proxy(&self, proxy: Option<String>) {
213        if let Ok(mut current) = self.native_proxy.lock() {
214            *current = proxy;
215        }
216    }
217
218    #[cfg(rings_native)]
219    pub(crate) fn native_proxy(&self) -> Option<String> {
220        self.native_proxy
221            .lock()
222            .ok()
223            .and_then(|proxy| proxy.clone())
224    }
225
226    #[cfg(all(test, rings_native))]
227    pub(crate) fn accounting_for_test(&self) -> OnionExitAccounting {
228        self.accounting.clone()
229    }
230
231    /// Set the local exit policy. `None` means client-only mode.
232    pub(crate) fn set_exit_policy(&self, policy: Option<OnionExitPolicy>) {
233        if let Ok(mut current) = self.exit_policy.lock() {
234            *current = policy;
235        }
236    }
237
238    /// Begin a client request expected to complete from the immediate return peer.
239    #[cfg(any(test, rings_browser))]
240    pub(crate) fn begin_request(
241        self: &Arc<Self>,
242        expected_return_peer: Did,
243        expected_exit: OnionExitDescriptor,
244        return_id: OnionReturnId,
245    ) -> Result<(OnionCircuitId, PendingOnionHttpsRequest)> {
246        let mut pending = lock(&self.pending)?;
247        for _ in 0..16 {
248            let id = OnionCircuitId::random();
249            if pending.contains_key(&id) {
250                continue;
251            }
252            let (sender, receiver) = oneshot::channel();
253            pending.insert(id, PendingRequest {
254                expected_return_peer,
255                expected_exit,
256                return_id,
257                sender,
258            });
259            return Ok((
260                id,
261                PendingOnionHttpsRequest::new(self.clone(), id, receiver),
262            ));
263        }
264        Err(Error::OnionRouteError(
265            OnionRouteError::CircuitIdAllocationFailed,
266        ))
267    }
268
269    #[cfg(any(test, rings_browser))]
270    fn cancel_request(&self, id: OnionCircuitId) {
271        if let Ok(mut pending) = self.pending.lock() {
272            pending.remove(&id);
273        }
274    }
275
276    /// Complete a pending HTTPS request with a signed response or error payload.
277    #[cfg(any(test, rings_browser))]
278    pub(crate) fn complete_payload(
279        &self,
280        from: Did,
281        id: OnionCircuitId,
282        payload: OnionAuthenticatedPayload,
283    ) {
284        let Some((pending, payload)) = self.take_pending_payload(from, id, payload) else {
285            return;
286        };
287        match decode_https_payload(payload) {
288            Ok(Some(OnionHttpsPayload::Response(response))) => {
289                let _ = pending.sender.send(Ok(OnionHttpsClientResponse {
290                    status: response.status,
291                    headers: response.headers,
292                    body: response.body,
293                }));
294            }
295            Ok(Some(OnionHttpsPayload::Error(failure))) => {
296                let _ =
297                    pending
298                        .sender
299                        .send(Err(Error::OnionRouteError(OnionRouteError::ExitFailure(
300                            failure,
301                        ))));
302            }
303            Ok(Some(OnionHttpsPayload::Request(_)) | None) => {
304                let _ = pending.sender.send(Err(Error::OnionRouteError(
305                    OnionRouteError::UnexpectedBackwardPayload,
306                )));
307            }
308            Err(error) => {
309                let _ = pending.sender.send(Err(error));
310            }
311        }
312    }
313
314    #[cfg(any(test, rings_browser))]
315    fn take_pending_payload(
316        &self,
317        from: Did,
318        id: OnionCircuitId,
319        payload: OnionAuthenticatedPayload,
320    ) -> Option<(PendingRequest, OnionCircuitPayload)> {
321        let mut pending = self.pending.lock().ok()?;
322        let request = pending.remove(&id)?;
323        if request.expected_return_peer != from {
324            pending.insert(id, request);
325            return None;
326        }
327        match payload.into_verified_payload(request.return_id, &request.expected_exit) {
328            Ok(verified) => Some((request, verified.payload)),
329            Err(error) => {
330                let _ = request.sender.send(Err(error));
331                None
332            }
333        }
334    }
335
336    pub(crate) fn exit_policy(&self) -> Option<OnionExitPolicy> {
337        self.exit_policy
338            .lock()
339            .ok()
340            .and_then(|policy| policy.clone())
341    }
342
343    fn admit_exit_request(
344        &self,
345        policy: &OnionExitPolicy,
346        circuit_id: OnionCircuitId,
347        return_peer: Did,
348        bytes: u64,
349    ) -> Result<OnionExitLease> {
350        self.accounting
351            .admit(policy, circuit_id, return_peer, bytes)
352    }
353
354    fn record_exit_bytes(&self, policy: &OnionExitPolicy, bytes: u64) -> Result<()> {
355        self.accounting.record_bytes(policy, bytes)
356    }
357
358    fn remaining_exit_bytes(&self, policy: &OnionExitPolicy) -> Result<Option<u64>> {
359        self.accounting.remaining_bytes(policy)
360    }
361
362    fn consume_forward_nonce(
363        &self,
364        from: Did,
365        circuit_id: OnionCircuitId,
366        nonce: OnionForwardNonce,
367    ) -> Result<()> {
368        let mut replays = lock(&self.forward_replays)?;
369        match replays.consume(
370            from,
371            OnionForwardReplayKey::new(circuit_id, nonce),
372            rings_core::utils::get_epoch_ms(),
373        ) {
374            ReplayAdmission::Consumed => Ok(()),
375            ReplayAdmission::Duplicate => {
376                Err(Error::OnionRouteError(OnionRouteError::ForwardReplay))
377            }
378            ReplayAdmission::Full => Err(Error::NoPermission),
379        }
380    }
381
382    #[cfg(test)]
383    pub(crate) fn pending_len(&self) -> usize {
384        self.pending
385            .lock()
386            .map(|pending| pending.len())
387            .unwrap_or(0)
388    }
389}
390
391/// Parse a full HTTPS URL and encode one client request for its target.
392#[cfg(any(test, rings_browser))]
393pub(crate) fn client_request_from_url(
394    url: &str,
395    request: OnionHttpsClientRequest,
396) -> Result<(OnionProxyTarget, OnionHttpsRequest)> {
397    let (target, path) = parse_https_url(url)?;
398    let request = client_request_with_default_path(&target, request, path.as_str())?;
399    Ok((target, request))
400}
401
402#[cfg(any(test, rings_browser))]
403fn client_request_with_default_path(
404    target: &OnionProxyTarget,
405    request: OnionHttpsClientRequest,
406    default_path: &str,
407) -> Result<OnionHttpsRequest> {
408    let path = request.path.as_deref().unwrap_or(default_path);
409    Ok(OnionHttpsRequest {
410        target: target.authority(),
411        method: normalize_method(&request.method),
412        path: normalize_path(path)?,
413        headers: request.headers,
414        body: request.body,
415    })
416}
417
418#[cfg(any(test, rings_browser))]
419fn parse_https_url(url: &str) -> Result<(OnionProxyTarget, String)> {
420    let url = url.trim();
421    let (scheme, rest) = url.split_once("://").ok_or_else(|| {
422        Error::HttpRequestError(
423            "browser HTTPS onion proxy request URL must be absolute".to_string(),
424        )
425    })?;
426    if !scheme.eq_ignore_ascii_case("https") {
427        return Err(Error::HttpRequestError(format!(
428            "browser HTTPS onion proxy only supports https URLs, got scheme {scheme:?}"
429        )));
430    }
431    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
432    let (authority, suffix) = rest.split_at(authority_end);
433    if authority.contains('@') {
434        return Err(Error::HttpRequestError(
435            "browser HTTPS onion proxy URLs must not contain userinfo".to_string(),
436        ));
437    }
438    let authority = https_authority_with_default_port(authority)?;
439    let target = OnionProxyTarget::parse_authority(authority.as_str())?;
440    Ok((target, url_path(suffix)))
441}
442
443#[cfg(any(test, rings_browser))]
444fn https_authority_with_default_port(authority: &str) -> Result<String> {
445    let authority = authority.trim();
446    if authority.is_empty() {
447        return Err(Error::HttpRequestError(
448            "browser HTTPS onion proxy URL host must not be empty".to_string(),
449        ));
450    }
451
452    if let Some(rest) = authority.strip_prefix('[') {
453        let Some((host, suffix)) = rest.split_once(']') else {
454            return Err(Error::HttpRequestError(format!(
455                "invalid IPv6 HTTPS onion proxy authority {authority:?}"
456            )));
457        };
458        if host.is_empty() {
459            return Err(Error::HttpRequestError(
460                "browser HTTPS onion proxy URL host must not be empty".to_string(),
461            ));
462        }
463        return if suffix.is_empty() {
464            Ok(format!("[{host}]:443"))
465        } else if let Some(port) = suffix.strip_prefix(':') {
466            if port.is_empty() {
467                Err(Error::HttpRequestError(format!(
468                    "HTTPS onion proxy authority {authority:?} has an empty port"
469                )))
470            } else {
471                Ok(authority.to_string())
472            }
473        } else {
474            Err(Error::HttpRequestError(format!(
475                "invalid IPv6 HTTPS onion proxy authority {authority:?}"
476            )))
477        };
478    }
479
480    if authority.contains('[') || authority.contains(']') {
481        return Err(Error::HttpRequestError(format!(
482            "invalid HTTPS onion proxy authority {authority:?}"
483        )));
484    }
485    let colon_count = authority.chars().filter(|ch| *ch == ':').count();
486    if colon_count > 1 {
487        return Err(Error::HttpRequestError(
488            "IPv6 HTTPS onion proxy URLs must use bracketed hosts".to_string(),
489        ));
490    }
491    if colon_count == 1 {
492        let Some((host, port)) = authority.rsplit_once(':') else {
493            return Err(Error::HttpRequestError(format!(
494                "invalid HTTPS onion proxy authority {authority:?}"
495            )));
496        };
497        if host.is_empty() || port.is_empty() {
498            return Err(Error::HttpRequestError(format!(
499                "invalid HTTPS onion proxy authority {authority:?}"
500            )));
501        }
502        Ok(authority.to_string())
503    } else {
504        Ok(format!("{authority}:443"))
505    }
506}
507
508#[cfg(any(test, rings_browser))]
509fn url_path(suffix: &str) -> String {
510    let path = suffix
511        .split_once('#')
512        .map_or(suffix, |(before_fragment, _)| before_fragment);
513    if path.is_empty() {
514        default_path()
515    } else if path.starts_with('?') {
516        format!("/{path}")
517    } else {
518        path.to_string()
519    }
520}
521
522/// Browser handler for HTTPS onion circuits.
523#[cfg(rings_browser)]
524pub(crate) struct BrowserOnionCircuitHandler {
525    https: Arc<OnionHttpsRuntime>,
526    session_sk: SessionSk,
527}
528
529#[cfg(rings_browser)]
530impl BrowserOnionCircuitHandler {
531    /// Create a browser circuit handler backed by the HTTPS runtime.
532    pub(crate) fn new(https: Arc<OnionHttpsRuntime>, session_sk: SessionSk) -> Self {
533        Self { https, session_sk }
534    }
535}
536
537#[cfg(rings_browser)]
538#[async_trait::async_trait(?Send)]
539impl OnionCircuitHandler for BrowserOnionCircuitHandler {
540    async fn handle_exit(&self, scope: &Scope, frame: OnionCircuitExitFrame) -> Result<()> {
541        let _ = try_handle_https_exit_payload(&self.https, &self.session_sk, scope, frame).await?;
542        Ok(())
543    }
544
545    async fn handle_client(
546        &self,
547        _scope: &Scope,
548        from: Did,
549        circuit_id: OnionCircuitId,
550        payload: OnionAuthenticatedPayload,
551    ) -> Result<()> {
552        self.https.complete_payload(from, circuit_id, payload);
553        Ok(())
554    }
555}
556
557pub(crate) async fn try_handle_https_exit_payload(
558    runtime: &Arc<OnionHttpsRuntime>,
559    session_sk: &SessionSk,
560    scope: &Scope,
561    frame: OnionCircuitExitFrame,
562) -> Result<bool> {
563    if !frame.payload.matches_service(ONION_PROXY_HTTPS_SERVICE) {
564        return Ok(false);
565    }
566    let Some(payload) = (match decode_https_payload(frame.payload) {
567        Ok(payload) => payload,
568        Err(Error::DecodeError) => return Ok(false),
569        Err(error) => return Err(error),
570    }) else {
571        return Ok(false);
572    };
573    let response = match payload {
574        OnionHttpsPayload::Request(request) => {
575            match execute_exit_fetch(
576                runtime,
577                &request,
578                frame.circuit_id,
579                frame.return_peer,
580                frame.forward_nonce,
581                frame.forward_sequence,
582            )
583            .await
584            {
585                Ok(response) => OnionHttpsPayload::Response(response),
586                Err(error) => OnionHttpsPayload::Error(OnionExitFailure::from_error(&error)),
587            }
588        }
589        OnionHttpsPayload::Response(_) | OnionHttpsPayload::Error(_) => return Ok(true),
590    };
591    send_backward(
592        &runtime.link_sender,
593        scope,
594        session_sk,
595        OnionBackwardPath::new(
596            frame.circuit_id,
597            frame.return_peer,
598            frame.return_session_public_key,
599            frame.client,
600        ),
601        OnionBackwardSequence::FIRST,
602        encode_https_payload(response)?,
603    )
604    .await?;
605    Ok(true)
606}
607
608pub(crate) async fn execute_exit_fetch(
609    runtime: &OnionHttpsRuntime,
610    request: &OnionHttpsRequest,
611    circuit_id: OnionCircuitId,
612    return_peer: Did,
613    forward_nonce: OnionForwardNonce,
614    forward_sequence: OnionForwardSequence,
615) -> Result<OnionHttpsResponse> {
616    if forward_sequence != OnionForwardSequence::FIRST {
617        return Err(Error::OnionRouteError(OnionRouteError::ForwardReplay));
618    }
619    runtime.consume_forward_nonce(return_peer, circuit_id, forward_nonce)?;
620    let target = OnionProxyTarget::parse_authority(&request.target)?;
621    let authority = target.authority();
622    let exit_target = OnionExitTarget::from_proxy_target(&target);
623    let Some(policy) = runtime.exit_policy() else {
624        return Err(Error::InvalidConfig(
625            "browser HTTPS onion exit is not enabled locally".to_string(),
626        ));
627    };
628    if !policy.allows_target(&exit_target) {
629        return Err(Error::NoPermission);
630    }
631    let request_body_bytes = usize_to_u64(request.body.len())?;
632    let _lease =
633        runtime.admit_exit_request(&policy, circuit_id, return_peer, request_body_bytes)?;
634    let body_limit = https_response_body_limit(runtime.remaining_exit_bytes(&policy)?);
635    if body_limit == 0 {
636        return Err(Error::NoPermission);
637    }
638    let url = format!("https://{}{}", authority, normalize_path(&request.path)?);
639    let response =
640        execute_https_request(&url, &target, request, body_limit, runtime, &policy).await?;
641    Ok(OnionHttpsResponse {
642        status: response.status,
643        headers: response.headers,
644        body: response.body,
645    })
646}
647
648pub(super) struct FetchResponse {
649    status: u16,
650    headers: Vec<(String, String)>,
651    body: Vec<u8>,
652}
653
654fn normalize_method(method: &str) -> String {
655    let method = method.trim();
656    if method.is_empty() {
657        default_method()
658    } else {
659        method.to_ascii_uppercase()
660    }
661}
662
663fn normalize_path(path: &str) -> Result<String> {
664    let path = path.trim();
665    if path.is_empty() {
666        return Ok(default_path());
667    }
668    if path.starts_with('/') {
669        return Ok(path.to_string());
670    }
671    if path.starts_with('?') {
672        return Ok(format!("/{path}"));
673    }
674    Err(Error::HttpRequestError(format!(
675        "browser HTTPS onion proxy path must start with '/' or '?', got {path:?}"
676    )))
677}
678
679fn default_method() -> String {
680    "GET".to_string()
681}
682
683fn default_path() -> String {
684    "/".to_string()
685}
686
687#[cfg(test)]
688mod tests;
689
690#[cfg(rings_browser)]
691mod browser;
692mod limits;
693#[cfg(rings_native)]
694mod native;
695#[cfg(any(test, rings_browser))]
696mod pending;