Skip to content

Commit 435ddad

Browse files
geekbrotherrootclaude
authored
fix(registry): harden against fail-open authorization bugs (#34)
Follow-up hardening to #33, which fixed `project_data_with_impl` mapping a 404 (project not found) to `Err` instead of `Ok(None)` — a fail-open bug, since callers treat registry errors as transient outages and let the request through. This addresses the same bug class across the rest of the crate. - registry/client.rs: the #33 fix only covered the base project fetch. The `limits` and `features` sub-fetches in the same function still mapped a 404 to `RegistryError::Response`, reintroducing the identical fail-open risk for plan/rate-limit and feature enforcement. Surface them as `Ok(None)`, matching `project_data_with_limits_impl`. - registry/error.rs + parse_http_response: split the catch-all `Response` error into `Forbidden` (403), `RateLimited` (429) and `ServerError` (5xx), so callers can fail closed on terminal denials instead of classifying every non-2xx as a retryable outage. - project/types/origin.rs: anchor the origin parser regex so malformed input is rejected rather than silently truncated to an unintended host; reject empty hostname labels so a `*.example.com` wildcard cannot match `.example.com`; compare scheme and hostname labels case-insensitively. Adds regression tests for each: limits/features 404 -> Ok(None), 403/5xx error mapping, empty-label and trailing-garbage rejection, and case-insensitive matching. Co-authored-by: root <root@v2202606366752468913.goodsrv.de> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7fafad6 commit 435ddad

3 files changed

Lines changed: 235 additions & 18 deletions

File tree

src/project/types/origin.rs

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ use {
55
};
66

77
/// Simplified URL parser regex. Extracts only the scheme (optional), hostname
8-
/// and port (optional).
8+
/// and port (optional). The expression is fully anchored: an optional trailing
9+
/// path (`/...`) is permitted and ignored, but any other unexpected suffix is
10+
/// rejected rather than silently truncated, so a malformed allow-list entry or
11+
/// origin can never parse to an unintended host.
912
static ORIGIN_PARSER_REGEX: Lazy<Regex> =
10-
Lazy::new(|| Regex::new(r"^(([^:]+)://)?([^:/]+)(:([\d]+))?").unwrap());
13+
Lazy::new(|| Regex::new(r"^(([^:]+)://)?([^:/]+)(:([\d]+))?(/.*)?$").unwrap());
1114

1215
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1316
enum MatchDirection {
@@ -39,8 +42,10 @@ impl Origin<'_> {
3942
}
4043

4144
fn matches_internal(&self, other: &Origin, dir: MatchDirection) -> bool {
42-
if self.scheme.is_some() && other.scheme.is_some() && self.scheme != other.scheme {
43-
return false;
45+
if let (Some(this_scheme), Some(other_scheme)) = (self.scheme, other.scheme) {
46+
if !this_scheme.eq_ignore_ascii_case(other_scheme) {
47+
return false;
48+
}
4449
}
4550

4651
if self.port.is_some() && other.port.is_some() && self.port != other.port {
@@ -67,7 +72,7 @@ fn match_fold_cb(res: bool, (this, other): (&&str, &&str)) -> bool {
6772
if this == &WILDCARD {
6873
res
6974
} else {
70-
res && this == other
75+
res && this.eq_ignore_ascii_case(other)
7176
}
7277
}
7378

@@ -94,7 +99,14 @@ impl<'a> TryFrom<&'a str> for Origin<'a> {
9499
.map(|m| m.as_str())
95100
.ok_or(OriginParseError::InvalidFormat)?;
96101

97-
let hostname_parts = hostname.split('.').collect();
102+
// Reject empty labels (leading/trailing/double dots). Otherwise a
103+
// wildcard allow-list entry such as `*.example.com` would match a
104+
// hostname like `.example.com`, because the wildcard matches the empty
105+
// label.
106+
let hostname_parts: Vec<&str> = hostname.split('.').collect();
107+
if hostname_parts.iter().any(|part| part.is_empty()) {
108+
return Err(OriginParseError::InvalidFormat);
109+
}
98110

99111
let port = caps
100112
.get(5)
@@ -454,4 +466,58 @@ mod test {
454466

455467
assert!(o1.matches_rev(&o2));
456468
}
469+
470+
#[test]
471+
fn rejects_empty_labels() {
472+
// Leading, trailing and double dots produce empty labels and must be
473+
// rejected so a `*` wildcard entry cannot match them.
474+
assert_eq!(
475+
Origin::try_from(".example.com"),
476+
Err(OriginParseError::InvalidFormat)
477+
);
478+
assert_eq!(
479+
Origin::try_from("example.com."),
480+
Err(OriginParseError::InvalidFormat)
481+
);
482+
assert_eq!(
483+
Origin::try_from("a..b.com"),
484+
Err(OriginParseError::InvalidFormat)
485+
);
486+
487+
// A wildcard entry must not match an empty-label hostname.
488+
let entry = Origin::try_from("*.example.com").unwrap();
489+
assert!(Origin::try_from(".example.com").is_err());
490+
// And a real subdomain still matches.
491+
assert!(entry.matches(&Origin::try_from("evil.example.com").unwrap()));
492+
}
493+
494+
#[test]
495+
fn rejects_unexpected_trailing_garbage() {
496+
// The regex is fully anchored: a malformed suffix is rejected rather
497+
// than silently truncated to a host.
498+
assert_eq!(
499+
Origin::try_from("domain.name:notaport"),
500+
Err(OriginParseError::InvalidFormat)
501+
);
502+
503+
// A trailing path is still accepted and ignored.
504+
assert_eq!(
505+
Origin::try_from("https://a.b.domain.name/some/path")
506+
.unwrap()
507+
.hostname(),
508+
"a.b.domain.name"
509+
);
510+
}
511+
512+
#[test]
513+
fn matching_is_case_insensitive() {
514+
let entry = Origin::try_from("https://App.Example.COM").unwrap();
515+
let origin = Origin::try_from("https://app.example.com").unwrap();
516+
assert!(entry.matches(&origin));
517+
518+
// Scheme comparison is case-insensitive too.
519+
let entry = Origin::try_from("HTTPS://app.example.com").unwrap();
520+
let origin = Origin::try_from("https://app.example.com").unwrap();
521+
assert!(entry.matches(&origin));
522+
}
457523
}

src/registry/client.rs

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -300,23 +300,24 @@ impl RegistryHttpClient {
300300
None => return Ok(None),
301301
};
302302

303+
// A missing sub-resource (registry returns 404) must surface as
304+
// `Ok(None)`, never `Err`, for the same reason as the base project
305+
// data above and to match `project_data_with_limits_impl`: an `Err`
306+
// makes callers treat a definitively-absent limit/feature record as a
307+
// transient registry outage and fail open.
303308
let limits = match request.include_limits {
304-
true => Some(
305-
self.project_limits(request.id)
306-
.await?
307-
.ok_or_else(|| RegistryError::Response("Limits not found".to_string()))?
308-
.plan_limits,
309-
),
309+
true => match self.project_limits(request.id).await? {
310+
Some(response) => Some(response.plan_limits),
311+
None => return Ok(None),
312+
},
310313
false => None,
311314
};
312315

313316
let features = match request.include_features {
314-
true => Some(
315-
self.project_features(request.id)
316-
.await?
317-
.ok_or_else(|| RegistryError::Response("Features not found".to_string()))?
318-
.features,
319-
),
317+
true => match self.project_features(request.id).await? {
318+
Some(response) => Some(response.features),
319+
None => return Ok(None),
320+
},
320321
false => None,
321322
};
322323

@@ -424,6 +425,21 @@ async fn parse_http_response<T: DeserializeOwned>(
424425
)),
425426
StatusCode::UNAUTHORIZED => Err(RegistryError::Config(INVALID_TOKEN_ERROR)),
426427
StatusCode::NOT_FOUND => Ok(None),
428+
// Distinguish terminal denials from transient outages so callers can
429+
// fail closed on the former instead of lumping everything into a
430+
// generic error that gets classified as a retryable outage.
431+
StatusCode::FORBIDDEN => Err(RegistryError::Forbidden(format!(
432+
"status={status} body={:?}",
433+
resp.text().await
434+
))),
435+
StatusCode::TOO_MANY_REQUESTS => Err(RegistryError::RateLimited(format!(
436+
"status={status} body={:?}",
437+
resp.text().await
438+
))),
439+
code if code.is_server_error() => Err(RegistryError::ServerError(format!(
440+
"status={status} body={:?}",
441+
resp.text().await
442+
))),
427443
_ => Err(RegistryError::Response(format!(
428444
"status={status} body={:?}",
429445
resp.text().await
@@ -571,6 +587,127 @@ mod test {
571587
assert!(response.is_none());
572588
}
573589

590+
#[tokio::test]
591+
async fn project_data_with_returns_none_when_limits_not_found() {
592+
let project_id = "a".repeat(32);
593+
594+
let mock_server = MockServer::start().await;
595+
596+
// The project itself exists...
597+
Mock::given(method(Method::Get))
598+
.and(path(format!("/internal/project/key/{project_id}")))
599+
.respond_with(ResponseTemplate::new(StatusCode::OK).set_body_json(mock_project_data()))
600+
.mount(&mock_server)
601+
.await;
602+
603+
// ...but its limits record is missing (404).
604+
Mock::given(method(Method::Get))
605+
.and(path("/internal/v1/project-limits"))
606+
.respond_with(ResponseTemplate::new(404))
607+
.mount(&mock_server)
608+
.await;
609+
610+
let request = crate::project::ProjectDataRequest::new(&project_id).include_limits();
611+
612+
let response = RegistryHttpClient::with_config(
613+
mock_server.uri(),
614+
Some(mock_server.uri()),
615+
"auth",
616+
TEST_ORIGIN,
617+
"st",
618+
"sv",
619+
Default::default(),
620+
)
621+
.unwrap()
622+
.project_data_with(request)
623+
.await
624+
.unwrap();
625+
626+
// A missing limits record must be `Ok(None)`, not an `Err`: an `Err`
627+
// makes callers treat it as a transient outage and fail open on plan /
628+
// rate-limit enforcement.
629+
assert!(response.is_none());
630+
}
631+
632+
#[tokio::test]
633+
async fn project_data_with_returns_none_when_features_not_found() {
634+
let project_id = "a".repeat(32);
635+
636+
let mock_server = MockServer::start().await;
637+
638+
Mock::given(method(Method::Get))
639+
.and(path(format!("/internal/project/key/{project_id}")))
640+
.respond_with(ResponseTemplate::new(StatusCode::OK).set_body_json(mock_project_data()))
641+
.mount(&mock_server)
642+
.await;
643+
644+
Mock::given(method(Method::Get))
645+
.and(path("/appkit/v1/config"))
646+
.respond_with(ResponseTemplate::new(404))
647+
.mount(&mock_server)
648+
.await;
649+
650+
let request = crate::project::ProjectDataRequest::new(&project_id).include_features();
651+
652+
let response = RegistryHttpClient::with_config(
653+
mock_server.uri(),
654+
Some(mock_server.uri()),
655+
"auth",
656+
TEST_ORIGIN,
657+
"st",
658+
"sv",
659+
Default::default(),
660+
)
661+
.unwrap()
662+
.project_data_with(request)
663+
.await
664+
.unwrap();
665+
666+
assert!(response.is_none());
667+
}
668+
669+
#[tokio::test]
670+
async fn forbidden_maps_to_forbidden_error() {
671+
let project_id = "a".repeat(32);
672+
673+
let mock_server = MockServer::start().await;
674+
675+
Mock::given(method(Method::Get))
676+
.and(path(format!("/internal/project/key/{project_id}")))
677+
.respond_with(ResponseTemplate::new(StatusCode::FORBIDDEN))
678+
.mount(&mock_server)
679+
.await;
680+
681+
let result = RegistryHttpClient::new(mock_server.uri(), "auth", TEST_ORIGIN, "st", "sv")
682+
.unwrap()
683+
.project_data(&project_id)
684+
.await;
685+
686+
// A 403 is a terminal denial and must be distinguishable from a
687+
// transient outage so callers can fail closed.
688+
assert!(matches!(result, Err(RegistryError::Forbidden(_))));
689+
}
690+
691+
#[tokio::test]
692+
async fn server_error_maps_to_server_error() {
693+
let project_id = "a".repeat(32);
694+
695+
let mock_server = MockServer::start().await;
696+
697+
Mock::given(method(Method::Get))
698+
.and(path(format!("/internal/project/key/{project_id}")))
699+
.respond_with(ResponseTemplate::new(StatusCode::INTERNAL_SERVER_ERROR))
700+
.mount(&mock_server)
701+
.await;
702+
703+
let result = RegistryHttpClient::new(mock_server.uri(), "auth", TEST_ORIGIN, "st", "sv")
704+
.unwrap()
705+
.project_data(&project_id)
706+
.await;
707+
708+
assert!(matches!(result, Err(RegistryError::ServerError(_))));
709+
}
710+
574711
#[tokio::test]
575712
async fn project_id_invalid_len() {
576713
let project_id = "a".repeat(31);

src/registry/error.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ pub enum RegistryError {
1717
#[error("invalid response: {0}")]
1818
Response(String),
1919

20+
/// The registry definitively rejected the request (HTTP 403). This is a
21+
/// terminal denial, not a transient outage: callers must fail closed.
22+
#[error("forbidden: {0}")]
23+
Forbidden(String),
24+
25+
/// The registry rate-limited the request (HTTP 429).
26+
#[error("rate limited: {0}")]
27+
RateLimited(String),
28+
29+
/// The registry failed to serve the request (HTTP 5xx). This is a
30+
/// transient, server-side error and is safe to retry.
31+
#[error("server error: {0}")]
32+
ServerError(String),
33+
2034
#[error("building URL: {0}")]
2135
UrlBuild(url::ParseError),
2236

0 commit comments

Comments
 (0)