use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use base64::Engine;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use reqwest::header::{HeaderValue, AUTHORIZATION, LOCATION};
use reqwest::{redirect, Certificate, Client, StatusCode};
use url::{Position, Url};
use crate::errors::{HostError, RedirectError};
use crate::local_address::{outside_addresses, AllowedLocal, Host};
use crate::CredentialKind;
const TIMEOUT: Duration = Duration::from_secs(20);
const MAX_REDIRECTS: usize = 5;
const HEADERS: [(&str, &str); 5] = [
("Accept", "*/*"),
("Accept-Encoding", "deflate, gzip"),
("Pragma", "no-cache"),
("Accept-Language", "*"),
("User-Agent", "FluidAttacksAPIClient/1.0"),
];
const REDIRECTS: [StatusCode; 5] = [
StatusCode::MOVED_PERMANENTLY,
StatusCode::FOUND,
StatusCode::SEE_OTHER,
StatusCode::TEMPORARY_REDIRECT,
StatusCode::PERMANENT_REDIRECT,
];
#[derive(Debug, Clone)]
struct Repository(Url);
impl Repository {
fn parse(raw: &str) -> Result<Self, RedirectError> {
Url::parse(raw)
.map_err(|error| RedirectError::Unparsable(error.to_string()))?
.try_into()
}
fn moved_to(&self, location: &str) -> Result<Self, RedirectError> {
self.info_refs()
.join(location)
.map_err(|error| RedirectError::Unparsable(error.to_string()))?
.try_into()
}
fn info_refs(&self) -> Url {
let mut asked = self.0.clone();
asked.set_path(&format!("{}/info/refs", self.path()));
asked.set_query(Some("service=git-upload-pack"));
asked
}
fn leads_to(&self, next: &Self) -> bool {
let drops_https = self.0.scheme() == "https" && next.0.scheme() == "http";
next.0.host() == self.0.host() && !drops_https
}
fn shown(&self) -> String {
format!("{}{}/", &self.0[..Position::BeforePath], self.path())
}
fn path(&self) -> &str {
self.0.path().trim_end_matches('/')
}
}
impl TryFrom<Url> for Repository {
type Error = RedirectError;
fn try_from(mut url: Url) -> Result<Self, Self::Error> {
if !matches!(url.scheme(), "http" | "https") {
return Err(RedirectError::NotHttp);
}
let alphanumeric = |first: char| first.is_ascii_alphanumeric();
if matches!(url.host(), Some(url::Host::Domain(name)) if !name.starts_with(alphanumeric)) {
return Err(RedirectError::InvalidHost);
}
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
let path = url.path();
let path = path.strip_suffix('/').unwrap_or(path);
let path = path.strip_suffix("/info/refs").unwrap_or(path).to_owned();
url.set_path(&path);
Ok(Self(url))
}
}
struct OutsideOnly(AllowedLocal);
impl Resolve for OutsideOnly {
fn resolve(&self, name: Name) -> Resolving {
let allowed = self.0;
Box::pin(async move {
let ips = outside_addresses(&Host::Name(name.as_str()), allowed).await?;
let addrs: Addrs = Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0)));
Ok(addrs)
})
}
}
pub async fn redirected_url(
url: &str,
credential: Option<&CredentialKind>,
) -> Result<String, RedirectError> {
follow(url, credential, AllowedLocal::default(), TIMEOUT).await
}
async fn follow(
url: &str,
credential: Option<&CredentialKind>,
allowed: AllowedLocal,
timeout: Duration,
) -> Result<String, RedirectError> {
let authorization = credential.map(authorization).transpose()?;
let original = Repository::parse(url)?;
match original.0.host() {
Some(url::Host::Ipv4(ip)) => refuse_local(IpAddr::V4(ip), allowed).await?,
Some(url::Host::Ipv6(ip)) => refuse_local(IpAddr::V6(ip), allowed).await?,
_ => {}
}
let client = client(allowed, timeout)?;
let mut current = original.clone();
for _ in 0..MAX_REDIRECTS {
let authorization = authorization
.as_ref()
.filter(|_| current.0.origin() == original.0.origin());
match moved(&client, ¤t, authorization).await? {
None => return Ok(current.shown()),
Some(next) if current.leads_to(&next) => current = next,
Some(_) => return Ok(original.shown()),
}
}
Ok(original.shown())
}
async fn moved(
client: &Client,
current: &Repository,
authorization: Option<&HeaderValue>,
) -> Result<Option<Repository>, RedirectError> {
let mut request = client.get(current.info_refs());
for (name, value) in HEADERS {
request = request.header(name, value);
}
if let Some(authorization) = authorization {
request = request.header(AUTHORIZATION, authorization);
}
let response = request.send().await.map_err(request_error)?;
let redirects = REDIRECTS.contains(&response.status());
let Some(location) = response.headers().get(LOCATION).filter(|_| redirects) else {
return Ok(None);
};
let location = std::str::from_utf8(location.as_bytes())
.map_err(|error| RedirectError::Unparsable(error.to_string()))?;
current.moved_to(location).map(Some)
}
fn authorization(credential: &CredentialKind) -> Result<HeaderValue, RedirectError> {
let engine = base64::engine::general_purpose::STANDARD;
let value = match credential {
CredentialKind::Https { user, password } => {
format!("Basic {}", engine.encode(format!("{user}:{password}")))
}
CredentialKind::Token {
token,
is_pat: true,
..
} => format!("Basic {}", engine.encode(format!(":{token}"))),
CredentialKind::Token { token, .. } => format!("Bearer {token}"),
CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. } => {
return Err(RedirectError::NotHttpsCredential)
}
};
let mut value =
HeaderValue::from_str(&value).map_err(|_| RedirectError::CredentialNotAHeader)?;
value.set_sensitive(true);
Ok(value)
}
async fn refuse_local(ip: IpAddr, allowed: AllowedLocal) -> Result<(), RedirectError> {
match outside_addresses(&Host::Ip(ip), allowed).await {
Err(HostError::Local(local)) => Err(RedirectError::Local(local)),
_ => Ok(()),
}
}
fn client(allowed: AllowedLocal, timeout: Duration) -> Result<Client, RedirectError> {
let roots = webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.map(|root| Certificate::from_der(root))
.collect::<Result<Vec<_>, _>>()
.map_err(RedirectError::Request)?;
Client::builder()
.no_proxy()
.redirect(redirect::Policy::none())
.timeout(timeout)
.tls_certs_only(roots)
.dns_resolver(OutsideOnly(allowed))
.build()
.map_err(RedirectError::Request)
}
fn request_error(error: reqwest::Error) -> RedirectError {
let error = error.without_url();
let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(&error);
while let Some(current) = cause {
if let Some(HostError::Local(local)) = current.downcast_ref::<HostError>() {
return RedirectError::Local(*local);
}
cause = current.source();
}
if error.is_timeout() {
RedirectError::Timeout(error)
} else {
RedirectError::Request(error)
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use super::{authorization, follow, redirected_url, Repository};
use crate::errors::RedirectError;
use crate::local_address::{AllowedLocal, LocalAddress};
use crate::CredentialKind;
const LOCALHOST: AllowedLocal = AllowedLocal {
localhost: true,
local_network: false,
};
const QUICK: Duration = Duration::from_secs(5);
type Answer = Arc<dyn Fn(&str) -> String + Send + Sync>;
struct Server {
port: u16,
heads: Arc<Mutex<Vec<String>>>,
}
impl Server {
fn url(&self, path: &str) -> String {
format!("http://127.0.0.1:{}{path}", self.port)
}
fn heads(&self) -> Vec<String> {
self.heads.lock().unwrap().clone()
}
fn first_header(&self, name: &str) -> Option<String> {
let heads = self.heads();
header(heads.first()?, name).map(str::to_owned)
}
}
async fn serve(answer: impl Fn(&str) -> String + Send + Sync + 'static) -> Server {
let answer: Answer = Arc::new(answer);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let heads = Arc::new(Mutex::new(Vec::new()));
let kept = Arc::clone(&heads);
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(answer_one(stream, Arc::clone(&answer), Arc::clone(&kept)));
}
});
Server { port, heads }
}
async fn answer_one(mut stream: TcpStream, answer: Answer, kept: Arc<Mutex<Vec<String>>>) {
let mut buffer = [0u8; 4096];
let read = stream.read(&mut buffer).await.unwrap_or(0);
let head = String::from_utf8_lossy(buffer.get(..read).unwrap_or_default()).into_owned();
let response = answer(&head);
kept.lock().unwrap().push(head);
let _ = stream.write_all(response.as_bytes()).await;
tokio::time::sleep(Duration::from_secs(30)).await;
}
fn redirect(status: u16, location: &str) -> String {
format!("HTTP/1.1 {status} Moved\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
}
fn ok() -> String {
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned()
}
fn moving_once_to(location: &'static str) -> impl Fn(&str) -> String + Send + Sync {
move |head| {
if head.starts_with("GET /org/repo/info/refs?service=git-upload-pack ") {
redirect(301, location)
} else {
ok()
}
}
}
fn moving_then_leaving(head: &str) -> String {
if head.starts_with("GET /org/repo/") {
redirect(302, "/org/repo.git")
} else {
redirect(302, "http://localhost:1/org/repo")
}
}
fn token() -> CredentialKind {
CredentialKind::Token {
token: "tok".to_owned(),
is_pat: false,
oauth_type: String::new(),
}
}
fn header<'a>(head: &'a str, name: &str) -> Option<&'a str> {
head.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
key.eq_ignore_ascii_case(name).then_some(value.trim())
})
}
#[tokio::test]
async fn a_location_is_followed_to_the_repository_git_lands_on() {
for (location, lands_on) in [
(
"/org/repo.git/info/refs?service=git-upload-pack",
"/org/repo.git/",
),
("../../repo.git/info/refs", "/org/repo.git/"),
("/org/ñ", "/org/%C3%B1/"),
] {
let server = serve(moving_once_to(location)).await;
let landed = follow(&server.url("/org/repo"), None, LOCALHOST, QUICK).await;
assert_eq!(landed.unwrap(), server.url(lands_on), "{location}");
}
}
#[tokio::test]
async fn leaving_the_host_after_a_hop_brings_back_the_original_url() {
let server = serve(moving_then_leaving).await;
let landed = follow(&server.url("/org/repo"), None, LOCALHOST, QUICK).await;
assert_eq!(landed.unwrap(), server.url("/org/repo/"));
assert_eq!(server.heads().len(), 2);
}
#[tokio::test]
async fn the_credential_reaches_only_the_origin_it_was_given_for() {
let other_port = serve(|_| ok()).await;
let target = other_port.url("/org/repo");
let first = serve(move |_| redirect(302, &target)).await;
let landed = follow(&first.url("/org/repo"), Some(&token()), LOCALHOST, QUICK).await;
assert_eq!(landed.unwrap(), other_port.url("/org/repo/"));
assert_eq!(
first.first_header("authorization").as_deref(),
Some("Bearer tok")
);
assert_eq!(other_port.first_header("authorization"), None);
}
#[tokio::test]
async fn only_the_redirects_of_the_rfc_are_followed() {
for status in [300, 304, 305, 306, 399] {
let server = serve(move |_| redirect(status, "/elsewhere")).await;
let landed = follow(&server.url("/org/repo"), None, LOCALHOST, QUICK).await;
assert_eq!(landed.unwrap(), server.url("/org/repo/"), "{status}");
assert_eq!(server.heads().len(), 1, "{status}");
}
}
#[tokio::test]
async fn the_body_is_never_waited_for() {
let server =
serve(|_| "HTTP/1.1 200 OK\r\nContent-Length: 1000000000\r\n\r\n".to_owned()).await;
let landed = follow(&server.url("/org/repo"), None, LOCALHOST, QUICK).await;
assert_eq!(landed.unwrap(), server.url("/org/repo/"));
let host = format!("127.0.0.1:{}", server.port);
assert_eq!(server.first_header("host"), Some(host));
}
#[tokio::test]
async fn after_five_redirects_the_original_url_comes_back() {
let server = serve(|_| redirect(302, "/org/repo.git")).await;
let landed = follow(&server.url("/org/repo"), None, LOCALHOST, QUICK).await;
assert_eq!(landed.unwrap(), server.url("/org/repo/"));
assert_eq!(server.heads().len(), 5);
}
#[tokio::test]
async fn a_server_that_does_not_answer_in_time_is_a_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/org/repo", listener.local_addr().unwrap());
let landed = follow(&url, None, LOCALHOST, Duration::from_millis(200)).await;
assert!(
matches!(landed, Err(RedirectError::Timeout(_))),
"{landed:?}"
);
}
#[tokio::test]
async fn a_local_address_is_refused_whether_written_or_resolved() {
for url in ["http://127.0.0.1:1/org/repo", "http://localhost:1/org/repo"] {
let landed = redirected_url(url, Some(&token())).await;
assert!(
matches!(landed, Err(RedirectError::Local(LocalAddress::Loopback))),
"{url}: {landed:?}"
);
}
}
#[tokio::test]
async fn a_name_that_does_not_resolve_is_a_failed_request() {
let landed = redirected_url("https://does-not-exist.invalid/org/repo", None).await;
assert!(
matches!(landed, Err(RedirectError::Request(_))),
"{landed:?}"
);
}
fn parsed(raw: &str) -> Repository {
Repository::parse(raw).unwrap()
}
#[test]
fn a_repository_comes_back_as_git_reaches_it() {
for (raw, shown) in [
(
"https://me:pw@gitlab.com/org/repo",
"https://gitlab.com/org/repo/",
),
(
"https://gitlab.com/org/repo?tab=1#readme",
"https://gitlab.com/org/repo/",
),
(
"https://gitlab.com/org/repo/info/refs/",
"https://gitlab.com/org/repo/",
),
(
"https://GitLab.com/org/repo/",
"https://gitlab.com/org/repo/",
),
("https://gitlab.com", "https://gitlab.com/"),
(
"https://gitlab.com/org/my app",
"https://gitlab.com/org/my%20app/",
),
(
"https://bücher.example/org/repo",
"https://xn--bcher-kva.example/org/repo/",
),
(
"https://gitlab.com:443/org/repo",
"https://gitlab.com/org/repo/",
),
(
"https://git.example.com:10443/org/repo",
"https://git.example.com:10443/org/repo/",
),
] {
assert_eq!(parsed(raw).shown(), shown, "{raw}");
}
}
#[test]
fn git_is_asked_at_the_info_refs_of_the_repository() {
for (raw, asked) in [
(
"https://gitlab.com/org/repo",
"https://gitlab.com/org/repo/info/refs?service=git-upload-pack",
),
(
"https://gitlab.com",
"https://gitlab.com/info/refs?service=git-upload-pack",
),
] {
assert_eq!(parsed(raw).info_refs().as_str(), asked, "{raw}");
}
}
#[test]
fn a_url_git_cannot_reach_over_http_is_refused() {
for (raw, refused) in [
("https://-evil.com/org/repo", "InvalidHost"),
("ssh://gitlab.com/org/repo", "NotHttp"),
("https://[gitlab.com/org/repo", "Unparsable"),
("git@gitlab.com:org/repo.git", "Unparsable"),
] {
let error = Repository::parse(raw).unwrap_err();
assert!(
format!("{error:?}").starts_with(refused),
"{raw}: {error:?}"
);
}
}
#[test]
fn a_redirect_is_followed_only_on_the_same_host_and_never_down_to_http() {
let original = parsed("http://gitlab.com/org/repo");
let secure = parsed("https://gitlab.com/org/repo");
assert!(original.leads_to(&parsed("https://gitlab.com/org/repo.git")));
assert!(original.leads_to(&parsed("http://gitlab.com:8080/org/repo")));
assert!(!original.leads_to(&parsed("https://login.gitlab.com/sso")));
assert!(!secure.leads_to(&parsed("http://gitlab.com/org/repo")));
}
#[test]
fn each_https_credential_is_sent_as_git_sends_it() {
let basic = CredentialKind::Https {
user: "me".to_owned(),
password: "pw".to_owned(),
};
let pat = CredentialKind::Token {
token: "tok".to_owned(),
is_pat: true,
oauth_type: String::new(),
};
assert_eq!(authorization(&basic).unwrap(), "Basic bWU6cHc=");
assert_eq!(authorization(&pat).unwrap(), "Basic OnRvaw==");
assert_eq!(authorization(&token()).unwrap(), "Bearer tok");
assert!(authorization(&basic).unwrap().is_sensitive());
}
#[test]
fn a_credential_a_header_cannot_carry_is_refused() {
let broken = CredentialKind::Token {
token: "tok\nX-Other: 1".to_owned(),
is_pat: false,
oauth_type: String::new(),
};
assert!(matches!(
authorization(&broken),
Err(RedirectError::CredentialNotAHeader)
));
}
}