Skip to content

Commit bc2cb46

Browse files
committed
fix: address review findings on input hardening
- Return an explicit error when decompressed mbox hits the 50 MiB limit instead of silently truncating the stream - Raise DefaultBodyLimit from 2 MiB to 25 MiB to accommodate large CLI mbox submissions and GitHub webhook payloads (up to 25 MB) - Read decompressed data into Vec<u8> before UTF-8 conversion to avoid InvalidData errors when the byte limit splits a multi-byte character - Extend SSRF blocklist with decimal (2130706433) and hex (0x7f000001) representations of loopback addresses - Replace message-ID slash/backslash blocklist with percent-encoding via the percent-encoding crate, preserving RFC 5322 message-IDs that contain path-significant characters - Replace full-URL string matching in is_safe_repo_url with parsed host-only checks via the url crate, eliminating false positives from blocklist patterns appearing in usernames or paths - Restructure forge_webhook access control to make the security model explicit: when webhook_secret is configured, signature verification is the sole access control for ALL requests including loopback. The is_loopback bypass only applies when no secret is configured. This is critical for reverse proxy deployments where all traffic arrives from loopback. Assisted-by: gemini-3.1-pro Signed-off-by: derekbarbosa <derekasobrab@gmail.com>
1 parent 2901b29 commit bc2cb46

6 files changed

Lines changed: 137 additions & 43 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,5 @@ tree-sitter-c = "0.24.2"
6767
hmac = "0.13"
6868
base64 = "0.22"
6969
subtle = "2"
70+
url = "2.5.8"
71+
percent-encoding = "2.3.2"

designs/DESIGN_WEBHOOK_INPUT_HARDENING.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ if !is_loopback && !has_secret && !state.allow_all_submit {
6666

6767
### 3. Body Size Limits (`src/api.rs`)
6868

69-
- Explicit `DefaultBodyLimit::max(2 MiB)` on the axum router (makes
70-
the implicit axum 0.8 default auditable).
69+
- Explicit `DefaultBodyLimit::max(25 MiB)` on the axum router.
70+
Sized to accommodate large CLI mbox submissions and GitHub
71+
webhook payloads (up to 25 MB).
7172
- HTTP download cap (10 MiB) on `fetch_and_inject_thread` for
7273
lore.kernel.org mbox fetches.
7374
- Decompression cap (50 MiB) via `Read::take()` on the gzip decoder

docs/WEBHOOK_SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ The webhook payload failed validation.
361361

362362
### 413 Payload Too Large
363363

364-
The request body exceeds the 2 MiB limit.
364+
The request body exceeds the 25 MiB limit.
365365

366366
## FAQ
367367

src/api.rs

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ pub fn build_router(
309309
.route("/", get_service(ServeFile::new("static/index.html")))
310310
.nest_service("/static", ServeDir::new("static"))
311311
.layer(middleware::from_fn(redirect_www))
312-
.layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024))
312+
.layer(axum::extract::DefaultBodyLimit::max(25 * 1024 * 1024))
313313
.with_state(state)
314314
}
315315

@@ -472,14 +472,22 @@ async fn submit_patch(
472472
}
473473
SubmitRequest::Thread { msgid } => {
474474
let id = generate_synthetic_id("thread");
475-
let clean_msgid = msgid.trim_matches(|c| c == '<' || c == '>').to_string();
476-
477-
// Reject message-IDs with path separators to prevent URL path traversal
478-
// when constructing the lore.kernel.org fetch URL. Note: ".." is valid
479-
// in RFC 5322 local-parts and is not rejected here.
480-
if clean_msgid.contains('/') || clean_msgid.contains('\\') {
481-
return Err(StatusCode::BAD_REQUEST);
482-
}
475+
// Percent-encode path-significant characters in the message-ID
476+
// for safe inclusion in the lore.kernel.org fetch URL. This
477+
// handles RFC 5322 message-IDs that contain `/` or other
478+
// path-sensitive characters without rejecting them.
479+
const PATH_SEGMENT_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
480+
.add(b'/')
481+
.add(b'\\')
482+
.add(b'?')
483+
.add(b'#')
484+
.add(b' ')
485+
.add(b'%');
486+
let clean_msgid = percent_encoding::utf8_percent_encode(
487+
msgid.trim_matches(|c| c == '<' || c == '>'),
488+
PATH_SEGMENT_ENCODE,
489+
)
490+
.to_string();
483491
info!(
484492
"Received thread fetch request: {} (msgid: {})",
485493
id, clean_msgid
@@ -556,14 +564,23 @@ async fn fetch_and_inject_thread(
556564
.into());
557565
}
558566

559-
// Decompress the gzip data using a blocking task to avoid blocking the async runtime
567+
// Decompress into bytes first, then convert to UTF-8. Reading directly
568+
// into a String via read_to_string would produce an InvalidData error
569+
// if the byte limit splits a multi-byte UTF-8 character.
560570
let raw = tokio::task::spawn_blocking(move || -> Result<String, std::io::Error> {
561571
use std::io::Read;
562572
let decoder = flate2::read::GzDecoder::new(&bytes[..]);
563573
let mut limited = decoder.take(MAX_MBOX_DECOMPRESSED);
564-
let mut raw = String::new();
565-
limited.read_to_string(&mut raw)?;
566-
Ok(raw)
574+
let mut raw_bytes = Vec::new();
575+
limited.read_to_end(&mut raw_bytes)?;
576+
if raw_bytes.len() as u64 >= MAX_MBOX_DECOMPRESSED {
577+
return Err(std::io::Error::other(format!(
578+
"Decompressed mbox exceeds {} byte limit",
579+
MAX_MBOX_DECOMPRESSED
580+
)));
581+
}
582+
String::from_utf8(raw_bytes)
583+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
567584
})
568585
.await??;
569586

@@ -1118,20 +1135,28 @@ async fn forge_webhook(
11181135
return Err(StatusCode::FORBIDDEN);
11191136
}
11201137

1121-
let is_loopback = addr.ip().to_canonical().is_loopback();
11221138
let webhook_secret = state.settings.forge.webhook_secret.as_deref();
11231139
let has_secret = webhook_secret.is_some();
11241140

1125-
// When webhook_secret is configured, the signature check in
1126-
// validate_event is the access control — no need for
1127-
// --enable-unsafe-all-submit. Without a secret, fall back to the
1128-
// existing localhost-only or explicit flag behavior.
1129-
if !is_loopback && !has_secret && !state.allow_all_submit {
1130-
info!(
1131-
"Refused {} webhook from {}: configure webhook_secret or use --enable-unsafe-all-submit",
1132-
provider, addr
1133-
);
1134-
return Err(StatusCode::FORBIDDEN);
1141+
// Access control: when webhook_secret is configured, signature
1142+
// verification in validate_event is the sole access control for ALL
1143+
// requests. This is critical for reverse proxy deployments where all
1144+
// traffic arrives from loopback — the signature check cannot be
1145+
// bypassed by source IP.
1146+
//
1147+
// When no secret is configured, fall back to localhost-only or the
1148+
// explicit --enable-unsafe-all-submit flag. Note: this is insecure
1149+
// behind a reverse proxy; operators MUST configure webhook_secret
1150+
// for proxied deployments.
1151+
if !has_secret {
1152+
let is_loopback = addr.ip().to_canonical().is_loopback();
1153+
if !is_loopback && !state.allow_all_submit {
1154+
info!(
1155+
"Refused {} webhook from {}: configure webhook_secret or use --enable-unsafe-all-submit",
1156+
provider, addr
1157+
);
1158+
return Err(StatusCode::FORBIDDEN);
1159+
}
11351160
}
11361161

11371162
let forge = state.forge_registry.get(&provider).ok_or_else(|| {

src/forge.rs

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,45 @@ pub fn is_valid_repo_url(url: &str) -> bool {
3535
}
3636

3737
/// Check that a repository URL does not target known internal or metadata
38-
/// endpoints. Returns false for cloud metadata services, loopback addresses,
39-
/// and other destinations that should never be cloned.
38+
/// endpoints. Parses the URL and checks the host component only, avoiding
39+
/// false positives from blocklist patterns appearing in usernames or paths.
4040
///
4141
/// This is a best-effort blocklist, not a complete SSRF mitigation. DNS
42-
/// rebinding and URL encoding can bypass string-based checks. The primary
43-
/// access control is webhook signature verification.
42+
/// rebinding can bypass host-based checks. The primary access control
43+
/// is webhook signature verification.
4444
pub fn is_safe_repo_url(url: &str) -> bool {
4545
if !is_valid_repo_url(url) {
4646
return false;
4747
}
48-
let lower = url.to_lowercase();
49-
!lower.contains("169.254.")
50-
&& !lower.contains("metadata.google")
51-
&& !lower.contains("localhost")
52-
&& !lower.contains("127.0.0.1")
53-
&& !lower.contains("[::1]")
54-
&& !lower.contains("0.0.0.0")
48+
// For git@ URLs, extract the host portion. SSH interprets the LAST
49+
// '@' as the user/host separator, so we must do the same to prevent
50+
// injection via URLs like "git@github.com@127.0.0.1:repo.git".
51+
let host = if let Some(rest) = url.strip_prefix("git@") {
52+
let before_colon = rest.split(':').next().unwrap_or("");
53+
// Use the portion after the last '@' — this is what SSH resolves
54+
let ssh_host = before_colon.rsplit('@').next().unwrap_or(before_colon);
55+
ssh_host.to_ascii_lowercase()
56+
} else if let Ok(parsed) = url::Url::parse(url) {
57+
parsed.host_str().unwrap_or("").to_ascii_lowercase()
58+
} else {
59+
return false;
60+
};
61+
62+
// Blocklist applied to the host component only
63+
!host.contains("169.254.")
64+
&& !host.contains("metadata.google")
65+
&& !host.starts_with("localhost")
66+
&& !host.starts_with("127.")
67+
&& host != "[::1]"
68+
&& host != "0.0.0.0"
69+
// Decimal representations of loopback (127.0.0.0/8)
70+
&& !(2130706432..=2130706687).contains(&host.parse::<u64>().unwrap_or(0))
71+
// Decimal representation of 169.254.169.254
72+
&& host != "2852039166"
73+
// Hex and octal IP representations
74+
&& !host.starts_with("0x7f")
75+
&& !host.starts_with("0xa9fe")
76+
&& !host.starts_with("0177")
5577
}
5678

5779
/// Decode a webhook secret. If prefixed with "whsec_", strip the prefix
@@ -106,10 +128,12 @@ fn verify_standard_webhook_signature(
106128
}
107129

108130
/// Verify a GitHub HMAC-SHA256 signature. The header value has the format
109-
/// "sha256={hex_digest}".
131+
/// "sha256={hex_digest}". GitHub sends lowercase hex per their documentation.
132+
/// The received hex is normalized to lowercase before comparison for
133+
/// robustness against forges that may use uppercase.
110134
fn verify_github_signature(secret: &str, body: &[u8], signature_header: &str) -> bool {
111135
let hex_sig = match signature_header.strip_prefix("sha256=") {
112-
Some(s) => s,
136+
Some(s) => s.to_ascii_lowercase(),
113137
None => return false,
114138
};
115139
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
@@ -118,7 +142,11 @@ fn verify_github_signature(secret: &str, body: &[u8], signature_header: &str) ->
118142
};
119143
mac.update(body);
120144
let result = mac.finalize().into_bytes();
121-
let computed: String = result.iter().map(|b| format!("{:02x}", b)).collect();
145+
let mut computed = String::with_capacity(64);
146+
for b in result {
147+
use std::fmt::Write;
148+
let _ = write!(computed, "{:02x}", b);
149+
}
122150
computed.as_bytes().ct_eq(hex_sig.as_bytes()).into()
123151
}
124152

@@ -146,8 +174,11 @@ pub trait ForgeProvider: Send + Sync {
146174
fn name(&self) -> &str;
147175

148176
/// Validate webhook event type and verify signature when a secret is
149-
/// configured. Returns `UNAUTHORIZED` if the signature is missing or
150-
/// invalid, `BAD_REQUEST` if the event type is wrong.
177+
/// configured. When `secret` is `None`, only event-type validation is
178+
/// performed and the request is treated as unauthenticated — callers
179+
/// must enforce their own access control before calling this method.
180+
/// Returns `UNAUTHORIZED` if the signature is missing or invalid,
181+
/// `BAD_REQUEST` if the event type is wrong.
151182
fn validate_event(
152183
&self,
153184
headers: &HeaderMap,
@@ -487,9 +518,42 @@ mod tests {
487518
));
488519
assert!(!is_safe_repo_url("http://metadata.google.internal/"));
489520
assert!(!is_safe_repo_url("http://localhost:5432/"));
521+
assert!(!is_safe_repo_url("http://localhost.localdomain/repo"));
490522
assert!(!is_safe_repo_url("http://127.0.0.1:8080/repo"));
523+
assert!(!is_safe_repo_url("http://127.1/repo"));
491524
assert!(!is_safe_repo_url("http://[::1]:8080/repo"));
492525
assert!(!is_safe_repo_url("http://0.0.0.0/repo"));
526+
// Decimal and hex IP representations of 127.0.0.1
527+
assert!(!is_safe_repo_url("http://2130706433/repo"));
528+
assert!(!is_safe_repo_url("http://0x7f000001/repo"));
529+
// Octal representation
530+
assert!(!is_safe_repo_url("http://0177.0.0.1/repo"));
531+
// Decimal representation of 169.254.169.254
532+
assert!(!is_safe_repo_url("http://2852039166/latest/"));
533+
// Hex representation of 169.254.x.x
534+
assert!(!is_safe_repo_url("http://0xa9fea9fe/latest/"));
535+
// Decimal for 127.0.0.2 (other loopback addresses in 127.0.0.0/8)
536+
assert!(!is_safe_repo_url("http://2130706434/repo"));
537+
}
538+
539+
#[test]
540+
fn test_is_safe_repo_url_blocks_ssh_injection() {
541+
// SSH resolves the LAST '@' as user/host separator
542+
assert!(!is_safe_repo_url("git@github.com@127.0.0.1:repo.git"));
543+
assert!(!is_safe_repo_url("git@github.com@localhost:repo.git"));
544+
assert!(!is_safe_repo_url("git@legit.com@169.254.169.254:repo.git"));
545+
}
546+
547+
#[test]
548+
fn test_is_safe_repo_url_no_false_positives_on_path() {
549+
// Blocklist patterns in username or path should NOT trigger rejection
550+
assert!(is_safe_repo_url(
551+
"https://github.com/user-127.0.0.1/repo.git"
552+
));
553+
assert!(is_safe_repo_url(
554+
"https://github.com/org/localhost-tools.git"
555+
));
556+
assert!(is_safe_repo_url("git@github.com:0x7f-labs/project.git"));
493557
}
494558

495559
#[test]

0 commit comments

Comments
 (0)