steid

@jamesgill /

steid/src/domain/import.rs
Rust12.9 KBCode·Blame·History·Raw
1//! Importing a repository from somewhere else.
2//!
3//! Two values live here: where the code is coming from, and how far the copy has got.
4//! Both are domain concerns — the address is a security boundary in the same way
5//! [`RepoName`](super::RepoName) is, and the state is a fact about the repository that
6//! storage and the page have to agree on.
7
8use std::{fmt, net::IpAddr, str::FromStr};
9
10use super::{DomainError, net::is_public};
11
12/// How far an import has got.
13///
14/// `None` on a repository — not a variant here — is the ordinary case: a repository
15/// that was never imported, or one whose import finished, is simply a repository. A
16/// `Ready` variant would mean every read had to remember that `Ready` and `NULL` are
17/// the same thing.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ImportState {
20 /// The mirror is running, or the process died while it was.
21 Importing,
22 /// The mirror failed. The reason is on the repository, not in here: a state that
23 /// carried its own message would be a state that differs from itself.
24 Failed,
25}
26
27impl ImportState {
28 pub fn as_str(self) -> &'static str {
29 match self {
30 Self::Importing => "importing",
31 Self::Failed => "failed",
32 }
33 }
34}
35
36impl fmt::Display for ImportState {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.write_str(self.as_str())
39 }
40}
41
42/// `Result`, never `Option`. A silently-defaulted state would either serve a
43/// half-cloned repository as if it were finished, or hide a finished one forever.
44impl FromStr for ImportState {
45 type Err = DomainError;
46
47 fn from_str(value: &str) -> Result<Self, Self::Err> {
48 match value {
49 "importing" => Ok(Self::Importing),
50 "failed" => Ok(Self::Failed),
51 other => Err(DomainError::validation(
52 "import state",
53 format!("unknown import state {other:?}"),
54 )),
55 }
56 }
57}
58
59/// Where a repository is being imported from.
60///
61/// **This value becomes an argument to `git clone`, and the host becomes a connection
62/// the server makes on a visitor's behalf.** That is a server-side request forgery hole
63/// unless it is closed here, in one place: a form that will fetch any URL given to it
64/// can be pointed at a cloud metadata endpoint, an internal admin panel, or a service
65/// on loopback that trusts anything reaching it.
66///
67/// So the rules are deliberately narrow, and each is a refusal rather than a
68/// normalisation:
69///
70/// - **`https://` only.** Not `http://` (a token or a redirect in cleartext), not
71/// `git://` (unauthenticated), not `ssh://` (needs a key Steid does not have), and
72/// above all not `file://`, which would read the server's own disk.
73/// - **No userinfo.** `https://user:token@host/…` would store somebody's credential in
74/// a column that is rendered on a page.
75/// - **No literal address in a range that is not the public internet.** Checked here
76/// for an address typed into the form; the same predicate is applied by the adapter
77/// to whatever DNS resolves the host to, because a hostname can point anywhere.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ImportSource(String);
80
81impl ImportSource {
82 /// Longest URL accepted. Far above any real clone address, and a bound on what a
83 /// form can put in a column.
84 pub const MAX_LEN: usize = 512;
85
86 /// Validates a URL typed by a person.
87 pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
88 let value = value.into();
89 let trimmed = value.trim();
90
91 let invalid = |reason: &str| DomainError::validation("import source", reason);
92
93 if trimmed.is_empty() {
94 return Err(invalid("must not be empty"));
95 }
96 if trimmed.len() > Self::MAX_LEN {
97 return Err(invalid("is too long"));
98 }
99 // Control characters and spaces would be passed to `git clone` as part of one
100 // argument; nothing legitimate contains them.
101 if trimmed.chars().any(|c| c.is_control() || c == ' ') {
102 return Err(invalid("must not contain spaces or control characters"));
103 }
104
105 let Some(rest) = strip_https(trimmed) else {
106 return Err(invalid("must start with https://"));
107 };
108
109 let authority = rest
110 .split(['/', '?', '#'])
111 .next()
112 .expect("split always yields one part");
113
114 if authority.contains('@') {
115 return Err(invalid(
116 "must not carry a username or password — Steid only imports public repositories",
117 ));
118 }
119
120 let host = host_of(authority).ok_or_else(|| invalid("must name a host"))?;
121
122 if host.is_empty() {
123 return Err(invalid("must name a host"));
124 }
125 if host.eq_ignore_ascii_case("localhost") || ends_with_ignore_case(host, ".localhost") {
126 return Err(invalid("must not point at this server"));
127 }
128 if let Ok(address) = host.parse::<IpAddr>()
129 && !is_public(address)
130 {
131 return Err(invalid(
132 "must not be a private, loopback or link-local address",
133 ));
134 }
135
136 Ok(Self(trimmed.to_owned()))
137 }
138
139 /// Wraps a value already validated on the way into the database.
140 pub fn from_trusted(value: impl Into<String>) -> Self {
141 Self(value.into())
142 }
143
144 pub fn as_str(&self) -> &str {
145 &self.0
146 }
147
148 /// The host to resolve before connecting. Never empty — [`new`](Self::new) refused
149 /// a URL without one.
150 pub fn host(&self) -> &str {
151 let rest = strip_https(&self.0).unwrap_or(&self.0);
152 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
153
154 host_of(authority).unwrap_or(authority)
155 }
156
157 /// The repository name this source suggests — its last path segment without `.git`.
158 ///
159 /// A suggestion, not a decision: it pre-fills the form's name field and is put
160 /// through [`RepoName`](super::RepoName) like anything else typed there. Returns an
161 /// empty string when the URL has no usable segment, which the form renders as an
162 /// empty box rather than guessing.
163 pub fn suggested_name(&self) -> &str {
164 let Some(rest) = strip_https(&self.0) else {
165 return "";
166 };
167
168 let path = rest
169 .split(['?', '#'])
170 .next()
171 .unwrap_or("")
172 .trim_end_matches('/');
173
174 // Split at the authority first: the host is not a repository name, so a URL
175 // with no path at all must suggest nothing rather than suggest its host.
176 let Some((_authority, tail)) = path.split_once('/') else {
177 return "";
178 };
179
180 let segment = tail.split('/').next_back().unwrap_or("");
181
182 segment.strip_suffix(".git").unwrap_or(segment)
183 }
184}
185
186impl fmt::Display for ImportSource {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 f.write_str(&self.0)
189 }
190}
191
192/// The part after `https://`, case-insensitively, or `None`.
193fn strip_https(value: &str) -> Option<&str> {
194 const SCHEME: &str = "https://";
195
196 if value.len() >= SCHEME.len() && value[..SCHEME.len()].eq_ignore_ascii_case(SCHEME) {
197 Some(&value[SCHEME.len()..])
198 } else {
199 None
200 }
201}
202
203fn ends_with_ignore_case(value: &str, suffix: &str) -> bool {
204 value.len() >= suffix.len() && value[value.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
205}
206
207/// The host out of an authority, with the port and IPv6 brackets removed.
208fn host_of(authority: &str) -> Option<&str> {
209 if let Some(rest) = authority.strip_prefix('[') {
210 // `[::1]:443` — the brackets exist precisely because the address contains colons.
211 return rest.split(']').next();
212 }
213
214 match authority.split_once(':') {
215 Some((host, _port)) => Some(host),
216 None => Some(authority),
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn import_state_round_trips_through_strings() {
226 for state in [ImportState::Importing, ImportState::Failed] {
227 assert_eq!(
228 state.as_str().parse::<ImportState>().expect("round trip"),
229 state
230 );
231 }
232 }
233
234 #[test]
235 fn unknown_import_state_is_an_error_not_a_default() {
236 assert!("ready".parse::<ImportState>().is_err());
237 assert!("".parse::<ImportState>().is_err());
238 }
239
240 #[test]
241 fn accepts_ordinary_public_clone_urls() {
242 for input in [
243 "https://github.com/rust-lang/mdBook.git",
244 "https://github.com/rust-lang/mdBook",
245 "https://gitlab.com/group/subgroup/project.git",
246 "HTTPS://example.com/thing.git",
247 "https://git.example.com:8443/thing.git",
248 " https://example.com/thing.git ",
249 ] {
250 assert!(
251 ImportSource::new(input).is_ok(),
252 "expected {input:?} to be accepted"
253 );
254 }
255 }
256
257 #[test]
258 fn refuses_every_scheme_but_https() {
259 for input in [
260 "http://example.com/thing.git",
261 "git://example.com/thing.git",
262 "ssh://git@example.com/thing.git",
263 "git@example.com:thing.git",
264 "file:///etc/passwd",
265 "/etc/passwd",
266 "//example.com/thing.git",
267 "httpsx://example.com/thing.git",
268 ] {
269 assert!(
270 ImportSource::new(input).is_err(),
271 "expected {input:?} to be refused"
272 );
273 }
274 }
275
276 /// The server-side request forgery boundary. Each of these, if allowed through,
277 /// becomes a connection Steid makes from inside its own network on a stranger's
278 /// instruction.
279 #[test]
280 fn refuses_addresses_that_are_not_the_public_internet() {
281 for input in [
282 "https://127.0.0.1/thing.git",
283 "https://127.1.2.3/thing.git",
284 "https://localhost/thing.git",
285 "https://LOCALHOST:8443/thing.git",
286 "https://api.localhost/thing.git",
287 "https://0.0.0.0/thing.git",
288 "https://10.0.0.5/thing.git",
289 "https://172.16.4.1/thing.git",
290 "https://192.168.1.1/thing.git",
291 // The AWS/GCP metadata endpoint, the canonical SSRF target.
292 "https://169.254.169.254/latest/meta-data",
293 "https://100.64.0.1/thing.git",
294 "https://[::1]/thing.git",
295 "https://[::]/thing.git",
296 "https://[fd00::1]/thing.git",
297 "https://[fe80::1]/thing.git",
298 "https://[::ffff:127.0.0.1]/thing.git",
299 ] {
300 assert!(
301 ImportSource::new(input).is_err(),
302 "expected {input:?} to be refused: it is not on the public internet"
303 );
304 }
305 }
306
307 #[test]
308 fn refuses_credentials_in_the_url() {
309 // They would be stored in a column the repository page renders.
310 assert!(ImportSource::new("https://user:token@example.com/thing.git").is_err());
311 assert!(ImportSource::new("https://token@example.com/thing.git").is_err());
312 }
313
314 #[test]
315 fn refuses_malformed_urls() {
316 for input in [
317 "",
318 " ",
319 "https://",
320 "https:///no/host.git",
321 "https://exa mple.com/thing.git",
322 "https://example.com/thing\n.git",
323 &format!("https://example.com/{}", "a".repeat(ImportSource::MAX_LEN)),
324 ] {
325 assert!(
326 ImportSource::new(input).is_err(),
327 "expected {input:?} to be refused"
328 );
329 }
330 }
331
332 #[test]
333 fn a_public_address_is_still_allowed() {
334 assert!(ImportSource::new("https://1.1.1.1/thing.git").is_ok());
335 assert!(ImportSource::new("https://[2606:4700::1111]/thing.git").is_ok());
336 }
337
338 #[test]
339 fn the_host_is_read_back_without_its_port_or_brackets() {
340 let host = |url: &str| ImportSource::new(url).expect("valid").host().to_owned();
341
342 assert_eq!(host("https://example.com/thing.git"), "example.com");
343 assert_eq!(
344 host("https://git.example.com:8443/thing.git"),
345 "git.example.com"
346 );
347 assert_eq!(
348 host("https://[2606:4700::1111]:8443/thing.git"),
349 "2606:4700::1111"
350 );
351 assert_eq!(host("https://example.com"), "example.com");
352 }
353
354 #[test]
355 fn the_last_path_segment_suggests_the_name() {
356 let name = |url: &str| {
357 ImportSource::new(url)
358 .expect("valid")
359 .suggested_name()
360 .to_owned()
361 };
362
363 assert_eq!(name("https://github.com/rust-lang/mdBook.git"), "mdBook");
364 assert_eq!(name("https://github.com/rust-lang/mdBook"), "mdBook");
365 assert_eq!(name("https://example.com/a/b/c.git/"), "c");
366 assert_eq!(name("https://example.com/thing.git?ref=main"), "thing");
367 // No path at all: nothing to suggest, and a guess of "example.com" would be
368 // both wrong and an invalid repository name.
369 assert_eq!(name("https://example.com"), "");
370 assert_eq!(name("https://example.com/"), "");
371 }
372}