hive-forge: stop embedding the forge token in clone URLs
This commit is contained in:
parent
aad5d3638f
commit
676f7715fd
6 changed files with 154 additions and 27 deletions
|
|
@ -4,6 +4,15 @@
|
|||
//! `client::Client::from_env` for the full resolution chain). Pairs
|
||||
//! with `pr-create --agit`: clone, edit + commit normally, then open a
|
||||
//! PR via the `AGit` ref.
|
||||
//!
|
||||
//! The clone URL itself carries no credentials, and the resulting
|
||||
//! checkout's `.git/config` never gets one either: `-c
|
||||
//! credential.helper=!hive-forge credential-helper` (baked into the
|
||||
//! `git clone` invocation, which persists into the new repo's config)
|
||||
//! makes git ask the `credential-helper` verb for a fresh token on every
|
||||
//! subsequent fetch/push instead. Previously the token itself rode in
|
||||
//! the clone URL and landed, durably, in every checkout's `.git/config` —
|
||||
//! a real credential leaked to every clone on disk, reported by atlas.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
|
|
@ -40,9 +49,17 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
|||
.map(str::to_owned)
|
||||
.with_context(|| format!("clone: cannot derive a destination dir from repo {repo}"))?,
|
||||
};
|
||||
let url = client.authed_git_url(repo);
|
||||
let url = client.plain_git_url(repo);
|
||||
let helper = match client.forge_label() {
|
||||
Some(label) => format!("!hive-forge credential-helper -f {label}"),
|
||||
None => "!hive-forge credential-helper".to_owned(),
|
||||
};
|
||||
|
||||
let mut git_args = vec!["clone".to_owned()];
|
||||
let mut git_args = vec![
|
||||
"clone".to_owned(),
|
||||
"-c".to_owned(),
|
||||
format!("credential.helper={helper}"),
|
||||
];
|
||||
if let Some(depth) = args.depth {
|
||||
git_args.push(format!("--depth={depth}"));
|
||||
}
|
||||
|
|
|
|||
80
hive-forge/src/verbs/credential_helper.rs
Normal file
80
hive-forge/src/verbs/credential_helper.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! `credential-helper <get|store|erase>` — implements git's
|
||||
//! credential-helper protocol (`gitcredentials(7)`) so a cloned repo's
|
||||
//! `.git/config` only ever holds a *reference* to this command, never
|
||||
//! the forge token itself. `clone` configures the new repo's
|
||||
//! `credential.helper` to invoke this subcommand; git then calls it
|
||||
//! fresh on every fetch/push, reading the token from its usual
|
||||
//! on-disk location (the per-agent `forge-token` file, or the
|
||||
//! `--forge <label>` sidecar) at auth time rather than the token
|
||||
//! having been baked into the remote URL and left durably in
|
||||
//! `.git/config`. A real leak of that shape, found and reported by
|
||||
//! atlas, is what this verb exists to close.
|
||||
//!
|
||||
//! Deliberately bypasses the normal `Client::from_env` construction in
|
||||
//! `main.rs` (see the special-case there): that path requires resolving
|
||||
//! an active repo, which can fail depending on where git happens to run
|
||||
//! this helper from (e.g. mid-`clone`, before the destination checkout
|
||||
//! exists) — and a credential lookup has no need for a repo anyway.
|
||||
//!
|
||||
//! Only `get` does anything: reads the `key=value` lines git sends on
|
||||
//! stdin, and if a `host=` line is present, refuses to answer unless it
|
||||
//! matches the resolved forge's own host (defence in depth against this
|
||||
//! helper ever being invoked outside the specific-URL scope `clone`
|
||||
//! configures it under). `store`/`erase` drain stdin and no-op — there
|
||||
//! is nothing durable to store or erase, the token always comes fresh
|
||||
//! from its file.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Args as ClapArgs, ValueEnum};
|
||||
|
||||
use crate::client::resolve_credentials;
|
||||
|
||||
#[derive(ClapArgs)]
|
||||
pub struct Args {
|
||||
/// The git credential-protocol operation git invokes this with.
|
||||
op: Op,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
enum Op {
|
||||
Get,
|
||||
Store,
|
||||
Erase,
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if credential resolution fails (no token
|
||||
/// provisioned for the active forge) or the `get` request's stdin
|
||||
/// can't be read, or if a `host=` line in the request doesn't match
|
||||
/// the resolved forge.
|
||||
pub fn run(args: Args, forge_label: Option<&str>) -> Result<()> {
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut input)
|
||||
.context("read credential request from stdin")?;
|
||||
let Op::Get = args.op else {
|
||||
// `store`/`erase`: protocol says read the request, do nothing.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let (base, token) = resolve_credentials(forge_label)?;
|
||||
if let Some(requested) = input.lines().find_map(|l| l.strip_prefix("host=")) {
|
||||
let expected = url::Url::parse(&base)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(str::to_owned));
|
||||
if expected.is_some_and(|e| e != requested.trim()) {
|
||||
bail!(
|
||||
"hive-forge credential-helper: refusing to hand the forge token to \
|
||||
host {requested:?} (configured forge is {base:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let user = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "oauth2".to_owned());
|
||||
println!("username={user}");
|
||||
println!("password={token}");
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ pub mod comment;
|
|||
pub mod comment_edit;
|
||||
pub mod comment_show;
|
||||
pub mod comments;
|
||||
pub mod credential_helper;
|
||||
pub mod dependency;
|
||||
pub mod diff;
|
||||
pub mod issue;
|
||||
|
|
|
|||
Loading…
Reference in a new issue