feat: gate git operations behind an `ACCESS_TOKEN` bearer token
commit
0def675feat: gate git operations behind an `ACCESS_TOKEN` bearer token
Git sends the token as HTTP Basic auth (username ignored, password is the
token), matching GitHub’s HTTPS auth. The gate compares the password against
ACCESS_TOKEN and answers a mismatch with 401 plus a WWW-Authenticate:
Basic challenge. When ACCESS_TOKEN is unset, auth is disabled so local and
test use stays frictionless.
feat: add --access-token/ACCESS_TOKEN arg to the server
feat: reject unauthenticated git requests with a 401 Basic challenge
refactor: expose http::header_value for the auth gate
Assisted-by: Claude:claude-opus-4-8
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/git-ents-server/src/http.rs
@@ -211,7 +211,7 @@
.status();
}
-fn header_value(request: &Request, field: &str) -> Option<String> {
+pub(crate) fn header_value(request: &Request, field: &str) -> Option<String> {
request
.headers()
.iter()
crates/git-ents-server/src/main.rs
@@ -25,6 +25,10 @@
#[arg(long, env = "GIT_PROJECT_ROOT", default_value = "/data/repos")]
data_dir: PathBuf,
+ /// Bearer token required for git operations; when unset, auth is disabled.
+ #[arg(long, env = "ACCESS_TOKEN")]
+ access_token: Option<String>,
+
/// Stop after handling this many requests.
#[arg(long)]
max_requests: Option<usize>,
@@ -54,6 +58,12 @@
for request in server.incoming_requests() {
if is_health(&request) {
let _health = request.respond(tiny_http::Response::from_string("ok"));
+ } else if args
+ .access_token
+ .as_deref()
+ .is_some_and(|token| !authorized(&request, token))
+ {
+ let _denied = request.respond(unauthorized());
} else if let Err(e) = http::handle(request, &args.data_dir) {
eprintln!("error: {e}");
}
@@ -70,3 +80,67 @@
fn is_health(request: &tiny_http::Request) -> bool {
matches!(request.url(), "/" | "/healthz")
}
+
+/// Check the request's HTTP Basic credentials against the expected token.
+///
+/// As with GitHub's HTTPS git auth, the username is ignored and the password
+/// field carries the bearer token.
+fn authorized(request: &tiny_http::Request, expected: &str) -> bool {
+ let Some(value) = http::header_value(request, "Authorization") else {
+ return false;
+ };
+ let Some(encoded) = value
+ .strip_prefix("Basic ")
+ .or_else(|| value.strip_prefix("basic "))
+ else {
+ return false;
+ };
+ let Some(decoded) = base64_decode(encoded.trim()) else {
+ return false;
+ };
+ let Some(colon) = decoded.iter().position(|byte| *byte == b':') else {
+ return false;
+ };
+ decoded.get(colon.saturating_add(1)..) == Some(expected.as_bytes())
+}
+
+/// A `401` carrying the Basic challenge git expects before retrying with creds.
+fn unauthorized() -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
+ let mut response = tiny_http::Response::from_string("unauthorized").with_status_code(401);
+ if let Ok(header) = tiny_http::Header::from_bytes(
+ &b"WWW-Authenticate"[..],
+ &br#"Basic realm="git-ents""#[..],
+ ) {
+ response.add_header(header);
+ }
+ response
+}
+
+/// Decode standard base64 with optional `=` padding; `None` on any bad input.
+fn base64_decode(input: &str) -> Option<Vec<u8>> {
+ fn sextet(byte: u8) -> Option<u32> {
+ let value = u32::from(byte);
+ match byte {
+ b'A'..=b'Z' => Some(value.saturating_sub(u32::from(b'A'))),
+ b'a'..=b'z' => Some(value.saturating_sub(u32::from(b'a')).saturating_add(26)),
+ b'0'..=b'9' => Some(value.saturating_sub(u32::from(b'0')).saturating_add(52)),
+ b'+' => Some(62),
+ b'/' => Some(63),
+ _ => None,
+ }
+ }
+
+ let bytes: &[u8] = input.trim_end_matches('=').as_bytes();
+ let mut out = Vec::new();
+ let mut acc: u32 = 0;
+ let mut bits: u32 = 0;
+ for &byte in bytes {
+ acc = (acc << 6) | sextet(byte)?;
+ bits = bits.saturating_add(6);
+ if bits >= 8 {
+ bits = bits.saturating_sub(8);
+ out.push(u8::try_from((acc >> bits) & 0xFF).ok()?);
+ }
+ }
+ Some(out)
+}