Compare commits

...
Author SHA1 Message Date
beardthelion 012bdb9df0 fix(gl): land the outstanding review asks on the icaptcha-proof flag
- help text on --icaptcha-proof so Clap renders what the flag accepts
- register_forwards_icaptcha_proof_header pins the supplied proof onto
  the register request as x-icaptcha-proof
- revert the 186 file-mode flips the contributor commits carried
  (644->755 noise unrelated to the feature)
- the redirect-forwarding concern is already covered on main: the
  client runs a same-origin-only redirect policy, so the proof header
  can never ride a redirect to a different origin; the cross-origin
  and proof-header tests already exist in http.rs
2026-11-10 22:24:28 -06:00
beardthelion 76891277e4 Merge remote-tracking branch 'origin/main' into pr-193 2026-11-10 22:14:17 -06:00
jilt 4652acfe26 gl: disable redirects for signed/proof requests, add end-to-end test 2026-07-16 22:31:29 +02:00
jilt 9e8a88abe5 gl: disable redirects, add proof test, improve error context
- Disable HTTP redirects in NodeClient to avoid leaking proof/signature headers
- Add send_signed_does_not_follow_redirect test
- Use specific error context for proof-bearing registration requests
- Add register_forwards_icaptcha_proof_header test
2026-07-14 12:32:02 +02:00
root d454de49ac gl: add --icaptcha-proof flag to register command
This allows users to provide a pre-obtained iCaptcha proof token when
registering with a node, sent as the x-icaptcha-proof header on the
initial POST /api/register request.

Usage:
  gl register --icaptcha-proof "<token>"
  GITLAWB_ICAPTCHA_PROOF="<token>" gl register

The existing automatic iCaptcha retry flow (403 -> solve -> retry) remains
unchanged and is still available if no initial proof is provided.
2026-07-12 00:36:40 +02:00
2 changed files with 70 additions and 2 deletions

No files matched your search

+30 -1
View File
@@ -148,6 +148,18 @@ impl NodeClient {
self.send_signed("POST", path, body).await
}
/// POST with JSON body + RFC 9421 signing and an optional user-provided
/// iCaptcha proof, sent on the initial request.
pub async fn post_with_proof(
&self,
path: &str,
body: &[u8],
initial_proof: Option<&str>,
) -> Result<reqwest::Response> {
self.send_signed_with_proof("POST", path, body, initial_proof)
.await
}
/// PUT with RFC 9421 signing + transparent iCaptcha solve/retry.
pub async fn put(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
self.send_signed("PUT", path, body).await
@@ -162,16 +174,32 @@ impl NodeClient {
/// `x-icaptcha-*` headers) solve it and retry the same signed request with
/// the proof header, up to [`MAX_ICAPTCHA_RETRIES`]. Emits an actionable
/// hint on a 401 "not an agent" (the old-CLI / unregistered failure mode).
/// Existing call path: no user-supplied proof on the first request.
async fn send_signed(
&self,
method: &str,
path: &str,
body: &[u8],
) -> Result<reqwest::Response> {
let mut proof: Option<String> = None;
self.send_signed_with_proof(method, path, body, None).await
}
/// Sign + send a write. `initial_proof`, if provided, is attached to the
/// first request. On a 403 iCaptcha challenge advertised via
/// `x-icaptcha-*` headers, solve and retry up to MAX_ICAPTCHA_RETRIES.
async fn send_signed_with_proof(
&self,
method: &str,
path: &str,
body: &[u8],
initial_proof: Option<&str>,
) -> Result<reqwest::Response> {
let mut proof = initial_proof.map(str::to_owned);
let mut attempts = 0;
loop {
let resp = self.send_once(method, path, body, proof.as_deref()).await?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED
@@ -195,6 +223,7 @@ impl NodeClient {
continue;
}
}
return Ok(resp);
}
}
+40 -1
View File
@@ -22,6 +22,11 @@ pub struct RegisterArgs {
)]
pub node: String,
/// A pre-obtained iCaptcha proof token, forwarded on the register request
/// instead of making the client solve a fresh challenge.
#[arg(long, env = "GITLAWB_ICAPTCHA_PROOF")]
pub icaptcha_proof: Option<String>,
/// Capabilities to advertise (comma-separated)
#[arg(
long,
@@ -56,7 +61,7 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
}))?;
let resp = client
.post("/api/register", &body)
.post_with_proof("/api/register", &body, args.icaptcha_proof.as_deref())
.await
.context("failed to connect to node")?;
@@ -160,6 +165,7 @@ mod tests {
capabilities: vec!["git:push".to_string(), "git:fetch".to_string()],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: None,
})
.await
.unwrap();
@@ -173,6 +179,36 @@ mod tests {
assert_eq!(content["node"].as_str().unwrap(), server.url());
}
/// A supplied `--icaptcha-proof` must ride the register request as the
/// `x-icaptcha-proof` header so an enforce-mode node accepts it without a
/// fresh solve round-trip.
#[tokio::test]
async fn register_forwards_icaptcha_proof_header() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/api/register")
.match_header("x-icaptcha-proof", "test.supplied.proof")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"Welcome","ucan":"eyJhbGci.test.token","trust_score":0.5,"expires":"2026-12-31"}"#)
.create_async()
.await;
run(RegisterArgs {
node: server.url(),
capabilities: vec!["git:push".to_string()],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: Some("test.supplied.proof".to_string()),
})
.await
.unwrap();
m.assert();
}
/// `gl register` READS the identity from the parent of `GITLAWB_KEY`, so it has
/// to WRITE the bootstrap token there too. Sending it to `~/.gitlawb` instead is
/// silent split-brain: registration prints success, and every command that later
@@ -201,6 +237,7 @@ mod tests {
capabilities: vec!["git:push".to_string()],
model: None,
dir: None,
icaptcha_proof: None,
})
.await
.unwrap();
@@ -235,6 +272,7 @@ mod tests {
capabilities: vec!["git:push".to_string()],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: None,
})
.await;
@@ -256,6 +294,7 @@ mod tests {
capabilities: vec![],
model: None,
dir: Some(dir.path().to_path_buf()),
icaptcha_proof: None,
})
.await;