aboutsummaryrefslogtreecommitdiffstats
use std::collections::HashSet;

use axum::http;
use chrono::{DateTime, Utc};
use gitolfs3_common::{Claims, Digest, Oid, Operation, SpecificClaims, generate_tag};

use crate::{
    api::{GitLfsErrorResponse, REPO_NOT_FOUND, make_error_resp},
    config::AuthorizationConfig,
};

pub struct Trusted(pub bool);

pub fn authorize_batch(
    conf: &AuthorizationConfig,
    repo_path: &str,
    public: bool,
    operation: Operation,
    headers: &http::HeaderMap,
) -> Result<Trusted, GitLfsErrorResponse<'static>> {
    // - No authentication required for downloading exported repos
    // - When authenticated:
    //   - Download / upload over presigned URLs
    // - When accessing over Tailscale:
    //   - No authentication required for downloading from any repo

    let claims = VerifyClaimsInput {
        specific_claims: SpecificClaims::BatchApi(operation),
        repo_path,
    };
    if !verify_claims(conf, &claims, headers)? {
        return authorize_batch_unauthenticated(conf, public, operation, headers);
    }
    Ok(Trusted(true))
}

fn authorize_batch_unauthenticated(
    conf: &AuthorizationConfig,
    public: bool,
    operation: Operation,
    headers: &http::HeaderMap,
) -> Result<Trusted, GitLfsErrorResponse<'static>> {
    let trusted = forwarded_from_trusted_host(headers, &conf.trusted_forwarded_hosts)?;
    match operation {
        Operation::Upload => {
            // Trusted users can clone all repositories (by virtue of accessing the server via a
            // trusted network). However, they can not push without proper authentication. Untrusted
            // users who are also not authenticated should not need to know which repositories exists.
            // Therefore, we tell untrusted && unauthenticated users that the repo doesn't exist, but
            // tell trusted users that they need to authenticate.
            if !trusted {
                return Err(REPO_NOT_FOUND);
            }
            Err(make_error_resp(
                http::StatusCode::FORBIDDEN,
                "Authentication required to upload",
            ))
        }
        Operation::Download => {
            // Again, trusted users can see all repos. For untrusted users, we first need to check
            // whether the repo is public before we authorize. If the user is untrusted and the
            // repo isn't public, we just act like it doesn't even exist.
            if !trusted {
                if !public {
                    return Err(REPO_NOT_FOUND);
                }
                return Ok(Trusted(false));
            }
            Ok(Trusted(true))
        }
    }
}

pub fn authorize_get(
    conf: &AuthorizationConfig,
    repo_path: &str,
    oid: Oid,
    headers: &http::HeaderMap,
) -> Result<(), GitLfsErrorResponse<'static>> {
    let claims = VerifyClaimsInput {
        specific_claims: SpecificClaims::Download(oid),
        repo_path,
    };
    if !verify_claims(conf, &claims, headers)? {
        return Err(make_error_resp(
            http::StatusCode::UNAUTHORIZED,
            "Repository not found",
        ));
    }
    Ok(())
}

fn forwarded_from_trusted_host(
    headers: &http::HeaderMap,
    trusted: &HashSet<String>,
) -> Result<bool, GitLfsErrorResponse<'static>> {
    if let Some(forwarded_host) = headers.get("X-Forwarded-Host") {
        if let Ok(forwarded_host) = forwarded_host.to_str() {
            if trusted.contains(forwarded_host) {
                return Ok(true);
            }
        } else {
            return Err(make_error_resp(
                http::StatusCode::NOT_FOUND,
                "Invalid X-Forwarded-Host header",
            ));
        }
    }
    Ok(false)
}

struct VerifyClaimsInput<'a> {
    specific_claims: SpecificClaims,
    repo_path: &'a str,
}

fn verify_claims(
    conf: &AuthorizationConfig,
    claims: &VerifyClaimsInput,
    headers: &http::HeaderMap,
) -> Result<bool, GitLfsErrorResponse<'static>> {
    const INVALID_AUTHZ_HEADER: GitLfsErrorResponse = make_error_resp(
        http::StatusCode::BAD_REQUEST,
        "Invalid authorization header",
    );

    let Some(authz) = headers.get(http::header::AUTHORIZATION) else {
        return Ok(false);
    };
    let authz = authz.to_str().map_err(|_| INVALID_AUTHZ_HEADER)?;
    let val = authz
        .strip_prefix("Gitolfs3-Hmac-Sha256 ")
        .ok_or(INVALID_AUTHZ_HEADER)?;
    let (tag, expires_at) = val.split_once(' ').ok_or(INVALID_AUTHZ_HEADER)?;
    let tag: Digest<32> = tag.parse().map_err(|_| INVALID_AUTHZ_HEADER)?;
    let expires_at: i64 = expires_at.parse().map_err(|_| INVALID_AUTHZ_HEADER)?;
    let expires_at = DateTime::<Utc>::from_timestamp(expires_at, 0).ok_or(INVALID_AUTHZ_HEADER)?;
    let expected_tag = generate_tag(
        Claims {
            specific_claims: claims.specific_claims,
            repo_path: claims.repo_path,
            expires_at,
        },
        &conf.key,
    )
    .ok_or_else(|| {
        make_error_resp(
            http::StatusCode::INTERNAL_SERVER_ERROR,
            "Internal server error",
        )
    })?;
    if tag != expected_tag {
        return Err(INVALID_AUTHZ_HEADER);
    }

    Ok(true)
}

#[test]
fn test_validate_claims() {
    use gitolfs3_common::Key;

    let key = "00232f7a019bd34e3921ee6c5f04caf48a4489d1be5d1999038950a7054e0bfea369ce2becc0f13fd3c69f8af2384a25b7ac2d52eb52c33722f3c00c50d4c9c2";
    let key: Key = key.parse().unwrap();

    let claims = Claims {
        expires_at: Utc::now() + std::time::Duration::from_secs(5 * 60),
        repo_path: "lfs-test.git",
        specific_claims: SpecificClaims::BatchApi(Operation::Download),
    };
    let tag = generate_tag(claims, &key).unwrap();
    let header_value = format!(
        "Gitolfs3-Hmac-Sha256 {tag} {}",
        claims.expires_at.timestamp()
    );

    let conf = AuthorizationConfig {
        key,
        trusted_forwarded_hosts: HashSet::new(),
    };
    let verification_claims = VerifyClaimsInput {
        repo_path: claims.repo_path,
        specific_claims: claims.specific_claims,
    };
    let mut headers = http::HeaderMap::new();
    headers.insert(
        http::header::AUTHORIZATION,
        header_value.try_into().unwrap(),
    );

    assert!(verify_claims(&conf, &verification_claims, &headers).unwrap());
}