forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
18.4 KiB
1//! End-to-end: pull and push mirrors against one real `forge` binary, talking to itself over
2//! HTTP smart git. The instance allows plain HTTP and allow-lists `127.0.0.1` in `[webhooks]`, so
3//! its own mirror worker may reach the loopback listener. Nothing else about the SSRF guard is
4//! relaxed.
5//!
6//! Scenarios:
7//! - pull mirror `dst` of `alice/src` (heads and tags) created through the new-repo form, then a
8//! second change picked up by "Sync now"; a push to `dst` is refused;
9//! - push mirror `pm` to `alice/target` (created empty) after a push to `pm`;
10//! - the mirror pages and the server log never contain the access token.
11
12mod common;
13use common::{BIN, Server, launch_with, pick_ports, tool_on_path, write_config_with};
14use std::collections::BTreeSet;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::process::{Command, Output};
18use std::time::{Duration, Instant};
19
20use reqwest::header::{LOCATION, SET_COOKIE};
21
22const ALICE_PW: &str = "alice-password-123";
23const WEBHOOK_TOML: &str = "\n[webhooks]\nallow_http = true\nallowed_hosts = [\"127.0.0.1\"]\n";
24const SYNC_TIMEOUT: Duration = Duration::from_secs(30);
25/// Push mirrors run after the 10s debounce, but the worker only re-checks on its 60s poll (or a
26/// wake-up, which fires before the debounce expires). Allow poll plus debounce plus slack.
27const PUSH_TIMEOUT: Duration = Duration::from_secs(90);
28
29struct Resp {
30 status: u16,
31 location: Option<String>,
32 set_cookies: Vec<String>,
33 body: String,
34}
35
36struct Http {
37 client: reqwest::Client,
38 base: String,
39}
40
41impl Http {
42 fn new(port: u16) -> Self {
43 let client = reqwest::Client::builder()
44 .redirect(reqwest::redirect::Policy::none())
45 .timeout(Duration::from_secs(60))
46 .build()
47 .expect("http client");
48 Self {
49 client,
50 base: format!("http://127.0.0.1:{port}"),
51 }
52 }
53
54 async fn get_cookie(&self, path: &str, cookie: &str) -> Resp {
55 let req = self
56 .client
57 .get(format!("{}{path}", self.base))
58 .header(reqwest::header::COOKIE, cookie);
59 send(req).await
60 }
61
62 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
63 let mut req = self.client.post(format!("{}{path}", self.base)).form(form);
64 if let Some(cookie) = cookie {
65 req = req.header(reqwest::header::COOKIE, cookie);
66 }
67 send(req).await
68 }
69}
70
71async fn send(req: reqwest::RequestBuilder) -> Resp {
72 let res = req.send().await.expect("http request");
73 let status = res.status().as_u16();
74 let location = res
75 .headers()
76 .get(LOCATION)
77 .and_then(|v| v.to_str().ok())
78 .map(str::to_owned);
79 let set_cookies = res
80 .headers()
81 .get_all(SET_COOKIE)
82 .iter()
83 .filter_map(|v| v.to_str().ok().map(str::to_owned))
84 .collect();
85 let body = res.text().await.unwrap_or_default();
86 Resp {
87 status,
88 location,
89 set_cookies,
90 body,
91 }
92}
93
94/// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored.
95fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
96 resp.set_cookies.iter().find_map(|set| {
97 let pair = set.split(';').next()?;
98 let (name, value) = pair.split_once('=')?;
99 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
100 })
101}
102
103/// The value of the first `<input name="...">` on a page.
104fn input_value(body: &str, name: &str) -> String {
105 let doc = scraper::Html::parse_document(body);
106 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
107 doc.select(&selector)
108 .next()
109 .and_then(|el| el.value().attr("value"))
110 .unwrap_or_else(|| panic!("no input named {name} on page"))
111 .to_owned()
112}
113
114/// The `action` of every form whose action ends with `suffix`, in page order.
115fn form_actions(body: &str, suffix: &str) -> Vec<String> {
116 let doc = scraper::Html::parse_document(body);
117 let selector = scraper::Selector::parse("form[action]").expect("selector");
118 doc.select(&selector)
119 .filter_map(|el| el.value().attr("action"))
120 .filter(|action| action.ends_with(suffix))
121 .map(str::to_owned)
122 .collect()
123}
124
125fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) {
126 let out = Command::new(BIN)
127 .arg("--config")
128 .arg(config)
129 .args(["admin", "create-user", username])
130 .args(extra)
131 .env("FORGE_PASSWORD", password)
132 .output()
133 .expect("run admin create-user");
134 assert!(
135 out.status.success(),
136 "create-user {username} failed:\n{}",
137 String::from_utf8_lossy(&out.stderr)
138 );
139}
140
141/// Runs git with credential helpers disabled and extra env. Returns the raw output.
142fn git_output(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output {
143 let mut cmd = Command::new("git");
144 cmd.current_dir(cwd)
145 .args(["-c", "credential.helper="])
146 .args(args)
147 .env("GIT_TERMINAL_PROMPT", "0");
148 for (key, value) in env {
149 cmd.env(key, value);
150 }
151 cmd.output().expect("run git")
152}
153
154/// Runs git, returning stdout trimmed. Panics on failure.
155fn git_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> String {
156 let out = git_output(cwd, args, env);
157 assert!(
158 out.status.success(),
159 "git {args:?} failed:\n{}",
160 String::from_utf8_lossy(&out.stderr)
161 );
162 String::from_utf8_lossy(&out.stdout).trim().to_owned()
163}
164
165fn git(cwd: &Path, args: &[&str]) -> String {
166 git_env(cwd, args, &[])
167}
168
169/// Writes `file`, commits it as `who` and returns the new HEAD.
170fn commit(repo: &Path, file: &str, contents: &str, message: &str) -> String {
171 fs::write(repo.join(file), contents).expect("write file");
172 git(repo, &["add", file]);
173 git_env(
174 repo,
175 &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message],
176 &[
177 ("GIT_AUTHOR_NAME", "alice"),
178 ("GIT_AUTHOR_EMAIL", "alice@example.test"),
179 ("GIT_COMMITTER_NAME", "alice"),
180 ("GIT_COMMITTER_EMAIL", "alice@example.test"),
181 ],
182 );
183 git(repo, &["rev-parse", "HEAD"])
184}
185
186/// Clones `url` into `into` (a path that does not exist yet) and returns it.
187fn clone(url: &str, into: &Path) -> PathBuf {
188 let parent = into.parent().expect("clone target has a parent");
189 git(
190 parent,
191 &["clone", "-q", url, into.to_str().expect("utf-8 path")],
192 );
193 into.to_path_buf()
194}
195
196/// The HTTPS remote for `owner/repo`, authenticated with a token.
197fn remote(port: u16, token: &str, owner: &str, repo: &str) -> String {
198 format!("http://alice:{token}@127.0.0.1:{port}/{owner}/{repo}.git")
199}
200
201/// The heads and tags a remote advertises, as sorted `sha refname` lines. HEAD is left out.
202/// Returns `None` if the remote cannot be listed.
203fn branches_and_tags(url: &str) -> Option<BTreeSet<String>> {
204 let out = git_output(&std::env::temp_dir(), &["ls-remote", url], &[]);
205 if !out.status.success() {
206 return None;
207 }
208 let text = String::from_utf8_lossy(&out.stdout);
209 Some(
210 text.lines()
211 .filter(|line| line.contains("refs/heads/") || line.contains("refs/tags/"))
212 .map(str::to_owned)
213 .collect(),
214 )
215}
216
217/// The sha a remote branch points at, if the branch exists.
218fn remote_head(url: &str, branch: &str) -> Option<String> {
219 let out = git_output(
220 &std::env::temp_dir(),
221 &["ls-remote", url, &format!("refs/heads/{branch}")],
222 &[],
223 );
224 if !out.status.success() {
225 return None;
226 }
227 String::from_utf8_lossy(&out.stdout)
228 .split_whitespace()
229 .next()
230 .map(str::to_owned)
231}
232
233/// Polls `check` every 200ms until it returns true, failing after `SYNC_TIMEOUT`.
234async fn wait_for(what: &str, check: impl Fn() -> bool) {
235 let deadline = Instant::now() + SYNC_TIMEOUT;
236 while !check() {
237 assert!(
238 Instant::now() < deadline,
239 "timed out after {SYNC_TIMEOUT:?} waiting for {what}"
240 );
241 tokio::time::sleep(Duration::from_millis(200)).await;
242 }
243}
244
245/// Logs in through the real `/login` form and returns the session cookie header.
246async fn login(http: &Http, username: &str, password: &str) -> String {
247 let page = http.get_cookie("/login", "").await;
248 assert_eq!(page.status, 200, "GET /login");
249 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
250 let precsrf = input_value(&page.body, "precsrf");
251 let res = http
252 .post_form(
253 "/login",
254 &[
255 ("username", username),
256 ("password", password),
257 ("next", "/"),
258 ("precsrf", &precsrf),
259 ],
260 Some(&precsrf_cookie),
261 )
262 .await;
263 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
264 cookie_named(&res, "forge_session").expect("session cookie after login")
265}
266
267/// The session's CSRF token, scraped from the token settings page.
268async fn csrf_token(http: &Http, session: &str) -> String {
269 let page = http.get_cookie("/settings/tokens", session).await;
270 assert_eq!(page.status, 200, "GET /settings/tokens");
271 input_value(&page.body, "csrf")
272}
273
274/// Creates an access token through `/settings/tokens` and returns the token shown once.
275async fn create_token(http: &Http, session: &str, name: &str) -> String {
276 let csrf = csrf_token(http, session).await;
277 let res = http
278 .post_form(
279 "/settings/tokens",
280 &[("csrf", &csrf), ("name", name), ("expires", "30")],
281 Some(session),
282 )
283 .await;
284 assert_eq!(res.status, 200, "create token: {}", res.body);
285 let doc = scraper::Html::parse_document(&res.body);
286 let selector = scraper::Selector::parse("pre.code code").expect("selector");
287 let token: String = doc
288 .select(&selector)
289 .next()
290 .expect("token shown after creation")
291 .text()
292 .collect();
293 let token = token.trim().to_owned();
294 assert!(token.starts_with("forge_"), "unexpected token shape");
295 token
296}
297
298/// Submits the new-repo form. `mirror` adds the "Mirror an existing repository" fields.
299async fn new_repo(http: &Http, session: &str, name: &str, mirror: Option<(&str, &str, &str)>) {
300 let page = http.get_cookie("/new", session).await;
301 assert_eq!(page.status, 200, "GET /new");
302 let csrf = input_value(&page.body, "csrf");
303 let mut form = vec![
304 ("csrf", csrf.as_str()),
305 ("name", name),
306 ("description", ""),
307 ("visibility", "public"),
308 ("default_branch", "main"),
309 ];
310 if let Some((url, username, secret)) = mirror {
311 form.extend([
312 ("mirror_url", url),
313 ("mirror_username", username),
314 ("mirror_secret", secret),
315 ("mirror_interval", "60"),
316 ]);
317 }
318 let res = http.post_form("/new", &form, Some(session)).await;
319 assert_eq!(res.status, 303, "create repo {name}: {}", res.body);
320 assert_eq!(
321 res.location.as_deref(),
322 Some(format!("/alice/{name}").as_str())
323 );
324}
325
326/// Adds a push mirror to `alice/repo` through its settings page.
327async fn add_push_mirror(http: &Http, session: &str, repo: &str, url: &str, secret: &str) {
328 let path = format!("/alice/{repo}/settings/mirrors");
329 let page = http.get_cookie(&path, session).await;
330 assert_eq!(page.status, 200, "GET {path}");
331 let csrf = input_value(&page.body, "csrf");
332 let res = http
333 .post_form(
334 &path,
335 &[
336 ("csrf", &csrf),
337 ("url", url),
338 ("username", "alice"),
339 ("secret", secret),
340 ("interval", "60"),
341 ],
342 Some(session),
343 )
344 .await;
345 assert_eq!(res.status, 303, "add push mirror: {}", res.body);
346}
347
348/// Clicks "Sync now" on the first mirror of `alice/repo`.
349async fn sync_now(http: &Http, session: &str, repo: &str) {
350 let path = format!("/alice/{repo}/settings/mirrors");
351 let page = http.get_cookie(&path, session).await;
352 assert_eq!(page.status, 200, "GET {path}");
353 let action = form_actions(&page.body, "/sync")
354 .into_iter()
355 .next()
356 .expect("a sync form on the mirrors page");
357 let csrf = input_value(&page.body, "csrf");
358 let res = http
359 .post_form(&action, &[("csrf", &csrf)], Some(session))
360 .await;
361 assert_eq!(res.status, 303, "sync now: {}", res.body);
362}
363
364/// A running server with its own temp data dir. Field order matters: the server is dropped (and
365/// killed) before the temp dir is removed.
366struct Booted {
367 _server: Server,
368 _root: tempfile::TempDir,
369 dir: PathBuf,
370 http: Http,
371 http_port: u16,
372}
373
374/// Creates alice (admin) and boots the server with plain HTTP and loopback mirrors allowed.
375async fn boot() -> Booted {
376 if !tool_on_path("git") {
377 panic!("`git` was not found on PATH");
378 }
379 // Keep the path short: the internal unix socket path is limited to 108 bytes.
380 let root = tempfile::Builder::new()
381 .prefix("mr2e")
382 .tempdir_in("/tmp")
383 .expect("temp dir");
384 let dir = root.path().to_path_buf();
385 let ports = pick_ports();
386 let config = write_config_with(&dir, ports, WEBHOOK_TOML);
387
388 run_admin(&config, "alice", &["--admin"], ALICE_PW);
389 let (server, ports) = launch_with(&dir, ports, WEBHOOK_TOML).await;
390 Booted {
391 _server: server,
392 _root: root,
393 dir,
394 http: Http::new(ports.http),
395 http_port: ports.http,
396 }
397}
398
399/// The server log, for assertion messages and the token leak check.
400fn server_log(b: &Booted) -> String {
401 fs::read_to_string(b.dir.join("serve.log")).unwrap_or_default()
402}
403
404#[tokio::test]
405async fn pull_and_push_mirrors_sync_over_smart_http() {
406 let b = boot().await;
407 let session = login(&b.http, "alice", ALICE_PW).await;
408 let token = create_token(&b.http, &session, "mirror-token").await;
409 let port = b.http_port;
410
411 // Source repo with a commit on main, a second branch and an annotated tag.
412 new_repo(&b.http, &session, "src", None).await;
413 let src_work = clone(
414 &remote(port, &token, "alice", "src"),
415 &b.dir.join("src-work"),
416 );
417 let first = commit(&src_work, "base.txt", "base\n", "base commit");
418 git(&src_work, &["push", "-q", "origin", "HEAD:refs/heads/main"]);
419 git(&src_work, &["tag", "-a", "v1", "-m", "release one"]);
420 git(&src_work, &["push", "-q", "origin", "refs/tags/v1"]);
421 git(
422 &src_work,
423 &["push", "-q", "origin", "HEAD:refs/heads/feature"],
424 );
425 let src_url = format!("http://127.0.0.1:{port}/alice/src.git");
426 let src_refs = branches_and_tags(&remote(port, &token, "alice", "src")).expect("list src");
427 assert!(src_refs.iter().any(|l| l.ends_with("refs/tags/v1")));
428 assert!(src_refs.iter().any(|l| l.ends_with("refs/heads/feature")));
429
430 // Pull mirror created through the new-repo form. Creation schedules the first sync now.
431 new_repo(&b.http, &session, "dst", Some((&src_url, "alice", &token))).await;
432 let dst_url = remote(port, &token, "alice", "dst");
433 wait_for("dst to hold src's heads and tags", || {
434 branches_and_tags(&dst_url).is_some_and(|refs| refs == src_refs)
435 })
436 .await;
437 assert_eq!(
438 remote_head(&dst_url, "main").as_deref(),
439 Some(first.as_str())
440 );
441
442 // A push to the pull mirror is refused by pre-receive.
443 let dst_work = clone(&dst_url, &b.dir.join("dst-work"));
444 commit(&dst_work, "local.txt", "local\n", "local change");
445 let refused = git_output(&dst_work, &["push", "origin", "HEAD:refs/heads/main"], &[]);
446 assert!(
447 !refused.status.success(),
448 "push to a pull mirror must be rejected"
449 );
450 let stderr = String::from_utf8_lossy(&refused.stderr);
451 assert!(
452 stderr.contains("pull mirror"),
453 "rejection should name the pull mirror:\n{stderr}"
454 );
455
456 // A new commit on src, picked up by "Sync now" on dst.
457 let second = commit(&src_work, "next.txt", "next\n", "second commit");
458 git(&src_work, &["push", "-q", "origin", "HEAD:refs/heads/main"]);
459 sync_now(&b.http, &session, "dst").await;
460 wait_for("dst main to follow src after sync now", || {
461 remote_head(&dst_url, "main").as_deref() == Some(second.as_str())
462 })
463 .await;
464
465 // Push mirror: an empty target repo receives pm's commit.
466 new_repo(&b.http, &session, "target", None).await;
467 new_repo(&b.http, &session, "pm", None).await;
468 let target_url = format!("http://127.0.0.1:{port}/alice/target.git");
469 add_push_mirror(&b.http, &session, "pm", &target_url, &token).await;
470 let pm_work = b.dir.join("pm-work");
471 fs::create_dir_all(&pm_work).expect("pm work dir");
472 git(&pm_work, &["init", "-q", "-b", "main"]);
473 let pushed = commit(&pm_work, "pm.txt", "pm\n", "pm commit");
474 git(
475 &pm_work,
476 &[
477 "push",
478 "-q",
479 &remote(port, &token, "alice", "pm"),
480 "HEAD:refs/heads/main",
481 ],
482 );
483 let target_remote = remote(port, &token, "alice", "target");
484 let delivered = {
485 let started = Instant::now();
486 let deadline = started + PUSH_TIMEOUT;
487 loop {
488 if remote_head(&target_remote, "main").as_deref() == Some(pushed.as_str()) {
489 eprintln!("push mirror delivered after {:?}", started.elapsed());
490 break true;
491 }
492 if Instant::now() >= deadline {
493 break false;
494 }
495 tokio::time::sleep(Duration::from_millis(200)).await;
496 }
497 };
498 if !delivered {
499 let page = b
500 .http
501 .get_cookie("/alice/pm/settings/mirrors", &session)
502 .await;
503 panic!(
504 "push mirror did not deliver {pushed} to target within {PUSH_TIMEOUT:?}\n\
505 pm mirrors page status {}:\n{}\nserver log:\n{}",
506 page.status,
507 page.body,
508 server_log(&b)
509 );
510 }
511
512 // The token never shows up in the mirror pages or the server log.
513 for repo in ["dst", "pm"] {
514 let page = b
515 .http
516 .get_cookie(&format!("/alice/{repo}/settings/mirrors"), &session)
517 .await;
518 assert_eq!(page.status, 200, "GET mirrors of {repo}");
519 assert!(
520 !page.body.contains(&token),
521 "the token leaked into the mirror page of {repo}"
522 );
523 }
524 assert!(
525 !server_log(&b).contains(&token),
526 "the token leaked into the server log"
527 );
528}