gglib_core/access/mod.rs
1#![doc = include_str!("README.md")]
2mod bearer;
3mod device_keys;
4mod host;
5
6#[cfg(test)]
7mod access_tests;
8#[cfg(test)]
9mod host_tests;
10
11pub use bearer::{BearerPolicy, bearer_matches};
12pub use device_keys::{
13 DeviceKeys, device_keys_path, load as load_device_keys, store as store_device_keys,
14};
15pub use host::{is_loopback_host, is_wildcard_host, normalize_host};
16
17use std::sync::Arc;
18
19use crate::cors::CorsConfig;
20use crate::ports::RemoteGatewayPort;
21
22/// Where the proxy's bearer token came from.
23///
24/// Carried alongside the resolved value so the startup banner can explain the
25/// decision rather than merely stating it — the same `(value, source)` shape
26/// [`resolve_context_size_with_source`](crate::server_config::resolve_context_size_with_source)
27/// uses.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum ApiKeySource {
30 /// Supplied on the command line or through `GGLIB_API_KEY`.
31 Flag,
32 /// Read from the stored `proxy_api_key` setting.
33 Settings,
34 /// Minted by this run because the bind was not loopback and nothing else
35 /// supplied one. Printed once, then persisted to settings.
36 Generated,
37 /// No token configured; the endpoint is unauthenticated.
38 #[default]
39 None,
40}
41
42/// Compare two byte strings without an early exit on the first difference, so
43/// response timing does not leak how many leading bytes of a secret a caller
44/// guessed right.
45///
46/// The length check is a deliberate exception: it leaks only the secret's
47/// length, which is not the secret, and comparing unequal-length slices has no
48/// meaningful definition.
49///
50/// Lives here because both `gglib-axum` and `gglib-proxy` guard bearer tokens
51/// and each had a byte-identical private copy. `normalize_host`, which both
52/// guards also call, already lived here. A hardening change to one private copy
53/// would not have been reported against the other by any lint.
54#[must_use]
55pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
56 if a.len() != b.len() {
57 return false;
58 }
59 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
60}
61
62/// Mint a bearer token for an endpoint that is about to be exposed off
63/// loopback.
64///
65/// A v4 UUID: 122 random bits from the OS CSPRNG, and already the shape this
66/// workspace uses for the desktop app's embedded-API token.
67#[must_use]
68pub fn generate_api_key() -> String {
69 uuid::Uuid::new_v4().to_string()
70}
71
72/// Who may reach the proxy, and how they prove it.
73///
74/// Two independent gates that happen to travel together, because both are
75/// decided at bind time and both are needed by the same layer of the router:
76///
77/// * [`api_key`](Self::api_key) is opt-in. `None` leaves the endpoint exactly
78/// as it behaved before authentication existed.
79/// * [`allowed_hosts`](Self::allowed_hosts) is always enforced. It is the
80/// DNS-rebinding defence, and it does not depend on a token being set.
81#[derive(Debug, Clone, Default)]
82pub struct ProxyAccessConfig {
83 /// Which origins the CORS layer accepts.
84 pub cors: CorsConfig,
85 /// Bearer token required on `/v1/*` and `/mcp`. `None` disables the check.
86 pub api_key: Option<String>,
87 /// Where [`api_key`](Self::api_key) came from, which decides whether it may
88 /// later be replaced by a settings write. A flag or environment value
89 /// outranks the stored setting, so it must not be overridden by one; every
90 /// other source is the stored setting, or absent, and tracks it.
91 pub api_key_source: ApiKeySource,
92 /// Host-header values accepted **in addition to** loopback, normalized to
93 /// lowercase with any port stripped. Loopback is always accepted and is
94 /// deliberately not listed here — it is a predicate
95 /// ([`is_loopback_host`]), so `127.0.0.2` and `::1` are covered without
96 /// anyone having to enumerate them.
97 pub allowed_hosts: Vec<String>,
98 /// The remote tunnel's owner, when this proxy may be reached through one
99 /// (ADR 0012). Travels with the access policy because it *is* one: it
100 /// decides whether a request that arrived through the tunnel may reach
101 /// `/mcp`.
102 /// `None` for an embedded server or a test, where nothing is listening
103 /// for the answers.
104 pub remote: Option<Arc<dyn RemoteGatewayPort>>,
105}
106
107/// Equality is over the *policy* — CORS, token, source, hosts — and not
108/// over [`remote`](ProxyAccessConfig::remote), which is a live object rather
109/// than a value. Two configs that differ only in whether a tunnel owner is
110/// attached describe the same access rules.
111impl PartialEq for ProxyAccessConfig {
112 fn eq(&self, other: &Self) -> bool {
113 self.cors == other.cors
114 && self.api_key == other.api_key
115 && self.api_key_source == other.api_key_source
116 && self.allowed_hosts == other.allowed_hosts
117 }
118}
119
120impl Eq for ProxyAccessConfig {}
121
122impl ProxyAccessConfig {
123 /// Build the access policy for a proxy about to bind `bind_host`.
124 ///
125 /// The bound address joins the allowlist automatically when it is a
126 /// concrete non-loopback address: someone who asked to bind `192.168.1.5`
127 /// plainly intends to be reached at `192.168.1.5`, and making them repeat
128 /// it as `--allowed-host` would be a rule with no purpose.
129 ///
130 /// A wildcard bind (`0.0.0.0` / `::`) gets no such inference. It names no
131 /// reachable address, so there is nothing to infer, and guessing the
132 /// machine's interface addresses would re-open exactly the hole the
133 /// allowlist exists to close. Those deployments must name their hostname
134 /// with `--allowed-host`.
135 #[must_use]
136 pub fn new(
137 cors: CorsConfig,
138 api_key: Option<String>,
139 bind_host: &str,
140 extra_hosts: Vec<String>,
141 ) -> Self {
142 let mut allowed_hosts: Vec<String> = Vec::with_capacity(extra_hosts.len() + 1);
143
144 if !is_loopback_host(bind_host)
145 && !is_wildcard_host(bind_host)
146 && let Some(host) = normalize_host(bind_host)
147 {
148 allowed_hosts.push(host);
149 }
150
151 for entry in extra_hosts {
152 if let Some(host) = normalize_host(&entry)
153 && !allowed_hosts.contains(&host)
154 {
155 allowed_hosts.push(host);
156 }
157 }
158
159 Self {
160 cors,
161 api_key,
162 api_key_source: ApiKeySource::default(),
163 allowed_hosts,
164 remote: None,
165 }
166 }
167
168 /// Attach the remote tunnel's owner.
169 ///
170 /// Separate from [`new`](Self::new) for the reason
171 /// [`with_key_source`](Self::with_key_source) is: only the supervisor has
172 /// one to attach, and every other construction site means "no tunnel".
173 #[must_use]
174 pub fn with_remote(mut self, remote: Option<Arc<dyn RemoteGatewayPort>>) -> Self {
175 self.remote = remote;
176 self
177 }
178
179 /// Record where the token came from.
180 ///
181 /// Separate from [`new`](Self::new) so that adding it did not change a
182 /// signature every caller spells out; the supervisor is the only layer
183 /// that knows the answer, and every other construction site means
184 /// [`ApiKeySource::None`].
185 #[must_use]
186 pub const fn with_key_source(mut self, source: ApiKeySource) -> Self {
187 self.api_key_source = source;
188 self
189 }
190
191 /// Whether a request carrying this `Host` header may proceed.
192 ///
193 /// This is the DNS-rebinding guard. A rebound page reaches the loopback
194 /// socket but still asks for the attacker's hostname, so a `Host` that is
195 /// neither loopback nor explicitly allowed did not come from anyone who
196 /// knows where this proxy actually lives.
197 ///
198 /// An absent or unparseable `Host` is rejected: HTTP/1.1 requires the
199 /// header, and a request that omits it has no claim to check.
200 #[must_use]
201 pub fn host_allowed(&self, host_header: &str) -> bool {
202 let Some(host) = normalize_host(host_header) else {
203 return false;
204 };
205 // Both sides are already normalized — lowercased, port stripped — so a
206 // plain equality comparison is the whole match.
207 is_loopback_host(&host) || self.allowed_hosts.contains(&host)
208 }
209}