Skip to main content

tailscale_mcp/
http.rs

1//! The Streamable HTTP transport, and everything that stands in front of it.
2//!
3//! Stdio has one client, reached over a pipe the operating system already
4//! decided who may open. HTTP has none of that: anything that can reach the
5//! socket can try, and a browser on the same machine can be made to try by a
6//! page the operator never visited. So the transport is the small part of this
7//! module and the checks are the rest.
8//!
9//! **What has to be true before a request reaches the handler**, in the order
10//! it is asked:
11//!
12//! 1. The `Host` header names something on the allow-list. Loopback, plus this
13//!    node's own tailnet names read from status at startup, plus whatever the
14//!    operator added. This is what stops DNS rebinding: a page that resolves
15//!    `evil.example` to `127.0.0.1` reaches the socket and arrives with the
16//!    wrong `Host`.
17//! 2. There is no `Origin`, or the `Origin` is on its own allow-list. A
18//!    request carrying one came from a page, and a page is not a client this
19//!    server has any reason to serve unless the operator said so.
20//! 3. The caller's address is under its rate limit.
21//! 4. The bearer token matches, compared in constant time.
22//!
23//! The body limit is the one check that is not here: rmcp's transport reads
24//! the body, so rmcp's transport is what caps it. See [`MAX_BODY_BYTES`].
25//!
26//! `GET /health` skips all four: it exists to be reachable by something that
27//! holds no credential, which is the whole point of a health check.
28//!
29//! **A token is optional on loopback and required everywhere else.** Binding
30//! to an address other than loopback without one refuses to start, and
31//! `--http-no-auth` is the only way past that — a flag rather than an
32//! omission, so that serving an unauthenticated tailnet address is something
33//! an operator did rather than something that happened.
34
35use std::collections::{BTreeSet, HashMap};
36use std::net::{IpAddr, SocketAddr};
37use std::sync::{Arc, Mutex};
38use std::time::{Duration, Instant};
39
40use axum::body::Body;
41use axum::extract::{ConnectInfo, Request, State};
42use axum::http::{HeaderMap, StatusCode};
43use axum::middleware::Next;
44use axum::response::{IntoResponse, Response};
45use axum::routing::get;
46
47use crate::context::SelfIdentity;
48
49/// Where `--http` listens when it is given no address.
50///
51/// Loopback, because anything else is a decision an operator should make
52/// rather than inherit. The port is above the ports people already run things
53/// on — 3000, 8000, 8080, 8443 — and is not registered to anything, so a
54/// default that collides is unlikely (Q86).
55pub const DEFAULT_BIND: &str = "127.0.0.1:8449";
56
57/// The largest request body accepted.
58///
59/// A policy file is the biggest thing a client sends, and the largest real one
60/// is a few hundred kilobytes; four megabytes leaves room for a tailnet far
61/// larger than any that exists without letting an unauthenticated caller make
62/// this server allocate for it.
63///
64/// Enforced by rmcp's own transport, which reads the body and so is the only
65/// thing positioned to stop reading it. `axum::extract::DefaultBodyLimit` is
66/// not: it sets an extension that axum's extractors consult, and the MCP path
67/// is a service that takes the body for itself.
68pub const MAX_BODY_BYTES: usize = 4 << 20;
69
70/// How many requests one address may make in [`RATE_WINDOW`].
71pub const RATE_BURST: u32 = 120;
72
73/// The window the burst is counted over.
74pub const RATE_WINDOW: Duration = Duration::from_secs(60);
75
76/// The path that answers without a token.
77pub const HEALTH_PATH: &str = "/health";
78
79/// The path the MCP transport is served at.
80pub const MCP_PATH: &str = "/mcp";
81
82/// Everything the checks need, built once at startup.
83#[derive(Debug, Clone)]
84pub struct Guard {
85    /// The bearer token, or `None` for a session that accepts any caller.
86    token: Option<Arc<tailscale_rest::Secret>>,
87    /// Host header values that may reach the handler, lowercased and without
88    /// a port.
89    hosts: Arc<BTreeSet<String>>,
90    /// Origins that may reach the handler, exactly as a browser sends them.
91    origins: Arc<BTreeSet<String>>,
92    limiter: Arc<Mutex<RateLimiter>>,
93    /// This node's peers by address, for naming a caller in the log.
94    peers: Arc<HashMap<IpAddr, String>>,
95}
96
97/// Why a request was refused.
98///
99/// One type so that the refusals are written in one place and cannot drift
100/// into four spellings of "no".
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Refusal {
103    UnknownHost,
104    ForbiddenOrigin,
105    RateLimited,
106    BadToken,
107}
108
109impl Refusal {
110    pub const fn status(self) -> StatusCode {
111        match self {
112            // Not 401: the caller's credential is not the problem, and a
113            // browser told to authenticate would ask a person for a password
114            // that would not help.
115            Self::UnknownHost | Self::ForbiddenOrigin => StatusCode::FORBIDDEN,
116            Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
117            Self::BadToken => StatusCode::UNAUTHORIZED,
118        }
119    }
120
121    pub const fn message(self) -> &'static str {
122        match self {
123            Self::UnknownHost => {
124                "this server does not answer for that `Host`; add it with `--http-allow-host`"
125            }
126            Self::ForbiddenOrigin => {
127                "this server does not answer requests from a browser page; add the origin with \
128                 `--http-allow-origin` if that is what you meant"
129            }
130            Self::RateLimited => "too many requests from this address; wait and try again",
131            Self::BadToken => "a bearer token is required and did not match",
132        }
133    }
134}
135
136impl IntoResponse for Refusal {
137    fn into_response(self) -> Response {
138        // A sentence, not a page: the caller is a program, and the sentence is
139        // what a person reads out of its logs.
140        (self.status(), format!("{}\n", self.message())).into_response()
141    }
142}
143
144/// Who is calling, as far as this server can tell.
145///
146/// Deliberately more than the transport needs today. `spec.md` asks that the
147/// per-request hook be "shaped so that identity-derived authorisation can be
148/// added later without changing the transport", and a hook that carried only
149/// an address would have to change shape the first time a rule wanted a name.
150#[derive(Debug, Clone)]
151pub struct Caller {
152    /// Where the request came from.
153    pub address: IpAddr,
154    /// The tailnet name of the node at that address, when it is one of this
155    /// node's peers.
156    pub name: Option<String>,
157}
158
159impl Caller {
160    /// What a log line calls this caller.
161    pub fn describe(&self) -> String {
162        match &self.name {
163            Some(name) => format!("{name} ({})", self.address),
164            None => self.address.to_string(),
165        }
166    }
167}
168
169impl Guard {
170    /// Everything the checks need, from the settings and what status said.
171    ///
172    /// The settings are one argument rather than three so that the token, the
173    /// hosts and the origins travel as what they are — one operator's answer
174    /// about one transport — rather than as three lists a caller could pass in
175    /// the wrong order.
176    pub fn for_session(
177        settings: &crate::config::HttpConfig,
178        identity: &SelfIdentity,
179        peers: HashMap<IpAddr, String>,
180    ) -> Self {
181        Self::new(
182            settings.token.clone(),
183            &settings.allow_hosts,
184            &settings.allow_origins,
185            identity,
186            peers,
187        )
188    }
189
190    /// Build the checks from what the operator asked for and what status said.
191    ///
192    /// The allow-list always contains loopback and `localhost` and this node's
193    /// own names; an operator adding to it is adding, never replacing, because
194    /// a list that could be narrowed to nothing is one an operator can lock
195    /// themselves out with.
196    pub fn new(
197        token: Option<tailscale_rest::Secret>,
198        extra_hosts: &[String],
199        origins: &[String],
200        identity: &SelfIdentity,
201        peers: HashMap<IpAddr, String>,
202    ) -> Self {
203        let mut hosts: BTreeSet<String> = ["localhost", "127.0.0.1", "[::1]", "::1"]
204            .iter()
205            .map(|host| (*host).to_owned())
206            .collect();
207        // This node's own tailnet names, so that reaching the server over the
208        // tailnet works without configuration — which is the case the HTTP
209        // transport exists for.
210        if let Some(dns_name) = &identity.dns_name {
211            let full = dns_name.trim_end_matches('.').to_ascii_lowercase();
212            if let Some(short) = full.split('.').next() {
213                hosts.insert(short.to_owned());
214            }
215            hosts.insert(full);
216        }
217        hosts.extend(identity.addresses.iter().map(|a| bracketed(a)));
218        hosts.extend(extra_hosts.iter().map(|host| normalise_host(host)));
219
220        Self {
221            token: token.map(Arc::new),
222            hosts: Arc::new(hosts),
223            origins: Arc::new(origins.iter().map(|o| normalise_origin(o)).collect()),
224            limiter: Arc::new(Mutex::new(RateLimiter::default())),
225            peers: Arc::new(peers),
226        }
227    }
228
229    /// The hosts this server answers for, so that rmcp's own transport can be
230    /// given the same list and the two cannot disagree.
231    pub fn hosts(&self) -> Vec<&str> {
232        self.hosts.iter().map(String::as_str).collect()
233    }
234
235    /// Ask every question, in order, and answer the first that says no.
236    ///
237    /// The order matters: a request from the wrong host is refused before its
238    /// token is looked at, so a page probing for a valid token learns nothing
239    /// from the timing of the refusal.
240    pub fn admit(&self, headers: &HeaderMap, address: IpAddr, now: Instant) -> Result<(), Refusal> {
241        let host = headers
242            .get(axum::http::header::HOST)
243            .and_then(|value| value.to_str().ok())
244            .unwrap_or_default();
245        if !self.hosts.contains(&normalise_host(host)) {
246            return Err(Refusal::UnknownHost);
247        }
248
249        if let Some(origin) = headers.get(axum::http::header::ORIGIN) {
250            let origin = normalise_origin(origin.to_str().unwrap_or_default());
251            if !self.origins.contains(&origin) {
252                return Err(Refusal::ForbiddenOrigin);
253            }
254        }
255
256        if !self
257            .limiter
258            .lock()
259            .map(|mut limiter| limiter.allow(address, now))
260            .unwrap_or(true)
261        {
262            return Err(Refusal::RateLimited);
263        }
264
265        let Some(expected) = &self.token else {
266            return Ok(());
267        };
268        let given = headers
269            .get(axum::http::header::AUTHORIZATION)
270            .and_then(|value| value.to_str().ok())
271            .and_then(|value| {
272                value
273                    .strip_prefix("Bearer ")
274                    .or_else(|| value.strip_prefix("bearer "))
275            })
276            .unwrap_or_default();
277        if same_secret(given.as_bytes(), expected.expose().as_bytes()) {
278            Ok(())
279        } else {
280            Err(Refusal::BadToken)
281        }
282    }
283
284    /// Who a request is from, as far as this server can tell.
285    pub fn caller(&self, address: IpAddr) -> Caller {
286        Caller {
287            address,
288            name: self.peers.get(&address).cloned(),
289        }
290    }
291}
292
293/// A host header, as the allow-list holds it: lowercased, without a port.
294///
295/// An IPv6 literal keeps its brackets, because that is what separates its
296/// colons from the port's.
297fn normalise_host(host: &str) -> String {
298    let host = host.trim().to_ascii_lowercase();
299    if let Some(rest) = host.strip_prefix('[') {
300        let closed = rest.split_once(']').map(|(inside, _)| inside);
301        return closed.map_or(host.clone(), |inside| format!("[{inside}]"));
302    }
303    host.split_once(':')
304        .map_or(host.clone(), |(name, _)| name.to_owned())
305}
306
307/// An origin as RFC 6454 compares them: scheme, host and port, with the
308/// default port for the scheme left off.
309///
310/// So an operator who listed `https://app.example` has also listed
311/// `https://App.Example:443`, which is the same origin written differently and
312/// is what a browser may actually send. `null` is a browser origin too — a
313/// sandboxed frame's — and is left as it is so that listing it is possible and
314/// deliberate.
315fn normalise_origin(origin: &str) -> String {
316    let origin = origin.trim();
317    let Some((scheme, rest)) = origin.split_once("://") else {
318        return origin.to_ascii_lowercase();
319    };
320    let scheme = scheme.to_ascii_lowercase();
321    let authority = normalise_host(rest.split('/').next().unwrap_or_default());
322    let port = rest
323        .split('/')
324        .next()
325        .and_then(|a| a.rsplit_once(':'))
326        .filter(|(before, _)| !before.ends_with(':') && !before.is_empty())
327        .and_then(|(_, port)| port.parse::<u16>().ok())
328        .filter(|port| !matches!((scheme.as_str(), port), ("http", 80) | ("https", 443)));
329    match port {
330        Some(port) => format!("{scheme}://{authority}:{port}"),
331        None => format!("{scheme}://{authority}"),
332    }
333}
334
335/// An address as a `Host` header spells it: IPv6 in brackets, IPv4 bare.
336fn bracketed(address: &str) -> String {
337    if address.contains(':') {
338        format!("[{}]", address.to_ascii_lowercase())
339    } else {
340        address.to_ascii_lowercase()
341    }
342}
343
344/// Compare two secrets without letting the time taken say how much matched.
345///
346/// Length is folded in rather than checked first, so that a wrong-length token
347/// takes the same path as a wrong one of the right length.
348fn same_secret(given: &[u8], expected: &[u8]) -> bool {
349    let mut difference = (given.len() ^ expected.len()) as u32;
350    let longest = given.len().max(expected.len());
351    for i in 0..longest {
352        let a = given.get(i).copied().unwrap_or(0);
353        let b = expected.get(i).copied().unwrap_or(0);
354        difference |= u32::from(a ^ b);
355    }
356    difference == 0
357}
358
359/// One bucket per address, refilled by the passage of time.
360#[derive(Debug, Default)]
361struct RateLimiter {
362    seen: HashMap<IpAddr, Bucket>,
363}
364
365#[derive(Debug, Clone, Copy)]
366struct Bucket {
367    /// How much of the burst is left.
368    left: f64,
369    /// When `left` was last brought up to date.
370    at: Instant,
371}
372
373impl RateLimiter {
374    /// Whether this address may make one more request now.
375    fn allow(&mut self, address: IpAddr, now: Instant) -> bool {
376        let rate = f64::from(RATE_BURST) / RATE_WINDOW.as_secs_f64();
377        // An address whose bucket has had time to refill completely is
378        // indistinguishable from one never seen, so it is forgotten rather
379        // than kept: a server up for a year should not hold one entry per
380        // address that ever reached it. The sweep is one pass over the
381        // addresses currently spending, which is the same order as the work
382        // the request itself is about to do.
383        self.seen.retain(|_, bucket| {
384            let refill = now.saturating_duration_since(bucket.at).as_secs_f64() * rate;
385            refill < f64::from(RATE_BURST) - bucket.left
386        });
387
388        let bucket = self.seen.entry(address).or_insert(Bucket {
389            left: f64::from(RATE_BURST),
390            at: now,
391        });
392        let refill = now.saturating_duration_since(bucket.at).as_secs_f64() * rate;
393        bucket.left = (bucket.left + refill).min(f64::from(RATE_BURST));
394        bucket.at = now;
395        if bucket.left < 1.0 {
396            return false;
397        }
398        bucket.left -= 1.0;
399        true
400    }
401}
402
403/// The middleware every request but the health check passes through.
404async fn admission(
405    State(guard): State<Guard>,
406    ConnectInfo(peer): ConnectInfo<SocketAddr>,
407    request: Request,
408    next: Next,
409) -> Response {
410    let address = peer.ip();
411    if let Err(refusal) = guard.admit(request.headers(), address, Instant::now()) {
412        tracing::warn!(
413            caller = guard.caller(address).describe(),
414            refusal = ?refusal,
415            "refused an HTTP request"
416        );
417        return refusal.into_response();
418    }
419    // Where identity-derived authorisation goes when there is any: the caller
420    // is resolved once, here, and everything downstream reads it from the
421    // request rather than resolving it again.
422    let caller = guard.caller(address);
423    tracing::info!(caller = caller.describe(), path = %request.uri().path(), "http request");
424    let mut request = request;
425    request.extensions_mut().insert(caller);
426    next.run(request).await
427}
428
429/// Build the router: the health check, the transport, and the checks in front.
430pub fn router<S>(guard: Guard, mcp: S) -> axum::Router
431where
432    S: tower::Service<Request<Body>, Response = Response, Error = std::convert::Infallible>
433        + Clone
434        + Send
435        + Sync
436        + 'static,
437    S::Future: Send + 'static,
438{
439    axum::Router::new()
440        .route_service(MCP_PATH, mcp)
441        .layer(axum::middleware::from_fn_with_state(
442            guard.clone(),
443            admission,
444        ))
445        // Outside the middleware, deliberately: a health check that needed a
446        // token would not answer the question it exists to answer.
447        .route(HEALTH_PATH, get(health))
448        .with_state(guard)
449}
450
451/// Build the transport, the checks, and the listener, and serve until stopped.
452///
453/// rmcp's own transport validates `Host` and caps the body too, and is handed
454/// the same allow-list and the same cap so the two cannot disagree. What it
455/// does not do is this ticket's origin rule — its empty origin list means "do
456/// not check" where the ticket means "refuse every browser" — nor a token, a
457/// rate limit or an open health endpoint, which is why the checks in front of
458/// it exist rather than being left to it (Q90).
459pub async fn serve(
460    settings: &crate::config::HttpConfig,
461    guard: Guard,
462    server: crate::server::TailscaleMcpServer,
463) -> std::io::Result<()> {
464    use rmcp::transport::streamable_http_server::{
465        StreamableHttpService, session::local::LocalSessionManager,
466    };
467
468    let transport = StreamableHttpService::new(
469        move || Ok(server.clone()),
470        Arc::new(LocalSessionManager::default()),
471        rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default()
472            .with_allowed_hosts(guard.hosts())
473            .with_max_request_body_bytes(MAX_BODY_BYTES)
474            .with_legacy_session_mode(settings.stateful),
475    );
476    // rmcp answers with a boxed body; axum's router routes `Body`. One map,
477    // here, rather than a body type spelled through every signature below.
478    let transport = tower::util::ServiceExt::<Request<Body>>::map_response(transport, |response| {
479        axum::http::Response::map(response, Body::new)
480    });
481
482    let listener = tokio::net::TcpListener::bind(settings.bind).await?;
483    tracing::info!(
484        address = %settings.bind,
485        authenticated = guard.token.is_some(),
486        sessions = settings.stateful,
487        "serving MCP over HTTP"
488    );
489    axum::serve(
490        listener,
491        router(guard, transport).into_make_service_with_connect_info::<SocketAddr>(),
492    )
493    .await
494}
495
496async fn health() -> impl IntoResponse {
497    (
498        StatusCode::OK,
499        [("content-type", "application/json")],
500        // The name, so that whatever polls this knows what answered, and not
501        // the version: this is the one route that answers a caller holding no
502        // credential, and the release it is looking at is not its business.
503        concat!(
504            "{\"status\":\"ok\",\"server\":\"",
505            env!("CARGO_PKG_NAME"),
506            "\"}\n"
507        ),
508    )
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn identity() -> SelfIdentity {
516        SelfIdentity {
517            node_id: Some("n1111111CNTRL".to_owned()),
518            numeric_id: None,
519            addresses: vec!["100.64.0.1".to_owned(), "fd7a:115c:a1e0::1".to_owned()],
520            dns_name: Some("workstation.example-tailnet.ts.net.".to_owned()),
521        }
522    }
523
524    fn guard(token: Option<&str>, origins: &[&str]) -> Guard {
525        Guard::new(
526            token.map(tailscale_rest::Secret::new),
527            &[],
528            &origins.iter().map(|o| (*o).to_owned()).collect::<Vec<_>>(),
529            &identity(),
530            HashMap::new(),
531        )
532    }
533
534    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
535        let mut headers = HeaderMap::new();
536        for (name, value) in pairs {
537            headers.insert(
538                axum::http::HeaderName::from_bytes(name.as_bytes()).expect("a header name"),
539                value.parse().expect("a header value"),
540            );
541        }
542        headers
543    }
544
545    fn here() -> IpAddr {
546        IpAddr::from([127, 0, 0, 1])
547    }
548
549    #[test]
550    fn this_nodes_own_names_are_allowed_without_configuration() {
551        let guard = guard(None, &[]);
552        for host in [
553            "localhost",
554            "127.0.0.1:8449",
555            "workstation",
556            "workstation.example-tailnet.ts.net",
557            "WORKSTATION.EXAMPLE-TAILNET.TS.NET:8449",
558            "100.64.0.1:8449",
559            "[fd7a:115c:a1e0::1]:8449",
560        ] {
561            assert_eq!(
562                guard.admit(&headers(&[("host", host)]), here(), Instant::now()),
563                Ok(()),
564                "`{host}` is one of this node's own names"
565            );
566        }
567    }
568
569    #[test]
570    fn a_host_this_server_does_not_answer_for_is_refused_before_anything_else() {
571        // DNS rebinding: the page resolved its own name to this address, so
572        // the socket is reached and the `Host` is the only thing that differs.
573        let guard = guard(Some("s3cret-token-value"), &[]);
574        assert_eq!(
575            guard.admit(
576                &headers(&[
577                    ("host", "evil.example"),
578                    ("authorization", "Bearer s3cret-token-value")
579                ]),
580                here(),
581                Instant::now()
582            ),
583            Err(Refusal::UnknownHost),
584            "and refused for the host, not for the token, which was right"
585        );
586    }
587
588    #[test]
589    fn the_token_has_to_match_and_a_missing_one_is_the_same_answer_as_a_wrong_one() {
590        let guard = guard(Some("s3cret-token-value"), &[]);
591        let host = ("host", "localhost");
592
593        assert_eq!(
594            guard.admit(
595                &headers(&[host, ("authorization", "Bearer s3cret-token-value")]),
596                here(),
597                Instant::now()
598            ),
599            Ok(())
600        );
601        assert_eq!(
602            guard.admit(&headers(&[host]), here(), Instant::now()),
603            Err(Refusal::BadToken)
604        );
605        assert_eq!(
606            guard.admit(
607                &headers(&[host, ("authorization", "Bearer wrong")]),
608                here(),
609                Instant::now()
610            ),
611            Err(Refusal::BadToken)
612        );
613        // A prefix of the real token is not the real token.
614        assert_eq!(
615            guard.admit(
616                &headers(&[host, ("authorization", "Bearer s3cret-token-valu")]),
617                here(),
618                Instant::now()
619            ),
620            Err(Refusal::BadToken)
621        );
622    }
623
624    #[test]
625    fn a_lowercase_bearer_scheme_is_admitted_like_the_capitalised_one() {
626        let guard = guard(Some("s3cret-token-value"), &[]);
627        assert_eq!(
628            guard.admit(
629                &headers(&[
630                    ("host", "localhost"),
631                    ("authorization", "bearer s3cret-token-value")
632                ]),
633                here(),
634                Instant::now()
635            ),
636            Ok(())
637        );
638    }
639
640    #[test]
641    fn comparing_a_secret_folds_the_length_in_rather_than_checking_it_first() {
642        assert!(same_secret(b"abc", b"abc"));
643        assert!(!same_secret(b"abc", b"abd"));
644        assert!(!same_secret(b"ab", b"abc"));
645        assert!(!same_secret(b"abcd", b"abc"));
646        assert!(same_secret(b"", b""));
647        // A zero byte at the end of the shorter one is not a match: the length
648        // difference is in the accumulator whatever the bytes say.
649        assert!(!same_secret(b"abc", b"abc\0"));
650    }
651
652    #[test]
653    fn the_rate_limit_triggers_and_then_recovers() {
654        let mut limiter = RateLimiter::default();
655        let start = Instant::now();
656        for i in 0..RATE_BURST {
657            assert!(
658                limiter.allow(here(), start),
659                "request {i} is inside the burst"
660            );
661        }
662        assert!(
663            !limiter.allow(here(), start),
664            "and one more is not, at the same instant"
665        );
666
667        // Enough time for one token to come back.
668        let later = start + RATE_WINDOW / RATE_BURST + Duration::from_millis(1);
669        assert!(limiter.allow(here(), later), "a bucket refills with time");
670        assert!(!limiter.allow(here(), later), "one at a time, though");
671
672        // A different address has its own bucket.
673        assert!(limiter.allow(IpAddr::from([127, 0, 0, 2]), start));
674    }
675
676    #[test]
677    fn an_address_that_has_gone_quiet_is_forgotten() {
678        let mut limiter = RateLimiter::default();
679        let start = Instant::now();
680        assert!(limiter.allow(here(), start));
681        assert_eq!(limiter.seen.len(), 1, "while it is still spending");
682
683        // A different address, long enough later that the first one's bucket
684        // has refilled: the first is forgotten rather than kept for ever.
685        assert!(limiter.allow(IpAddr::from([127, 0, 0, 2]), start + RATE_WINDOW * 2));
686        assert_eq!(
687            limiter.seen.keys().collect::<Vec<_>>(),
688            vec![&IpAddr::from([127, 0, 0, 2])],
689            "a server up for a year should not hold one entry per address that ever reached it"
690        );
691    }
692
693    #[test]
694    fn an_origin_is_compared_as_an_origin_and_not_as_a_string() {
695        // What a browser sends and what an operator typed are the same origin
696        // written two ways, and RFC 6454 says so.
697        assert_eq!(
698            normalise_origin("https://App.Example"),
699            "https://app.example"
700        );
701        assert_eq!(
702            normalise_origin("https://app.example:443"),
703            "https://app.example",
704            "the default port for the scheme is not part of the origin"
705        );
706        assert_eq!(normalise_origin("http://localhost:80"), "http://localhost");
707        assert_eq!(
708            normalise_origin("http://localhost:3000/some/page"),
709            "http://localhost:3000",
710            "a path is not part of an origin either"
711        );
712        assert_eq!(normalise_origin("null"), "null");
713
714        let guard = guard(None, &["https://app.example"]);
715        assert_eq!(
716            guard.admit(
717                &headers(&[("host", "localhost"), ("origin", "https://App.Example:443")]),
718                here(),
719                Instant::now()
720            ),
721            Ok(()),
722            "listing an origin lists it however a browser spells it"
723        );
724    }
725}