Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ All notable changes to this project will be documented in this file.
See [our internal issue](https://github.com/stackabletech/hdfs-operator/issues/626) and [the fix](https://github.com/kube-rs/kube/pull/2042) for details ([#871]).
- The reconciler now applies resources and derives the cluster status in discrete
apply and update_status steps for the `opa_controller` ([#872]).
- The user-info-fetcher Keycloak and Entra backends now encode user ids and names as a single URL
path segment, and the Keycloak backend sends the username as a query parameter. An empty, `.` or
`..` id or name, or one containing a line break, is rejected with `400` ([#909]).
- The user-info-fetcher Keycloak backend now keeps a `rootPath` without a trailing slash ([#909]).

[#873]: https://github.com/stackabletech/opa-operator/pull/873
[#852]: https://github.com/stackabletech/opa-operator/pull/852
Expand All @@ -113,6 +117,7 @@ All notable changes to this project will be documented in this file.
[#905]: https://github.com/stackabletech/opa-operator/pull/905
[#906]: https://github.com/stackabletech/opa-operator/pull/906
[#908]: https://github.com/stackabletech/opa-operator/pull/908
[#909]: https://github.com/stackabletech/opa-operator/pull/909

## [26.7.0] - 2026-07-21

Expand Down
10 changes: 7 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 19 additions & 3 deletions Cargo.nix

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ ldap3 = { version = "0.12", features = ["gssapi", "tls"] }
md5 = "0.8"
moka = { version = "0.12", default-features = false, features = ["future"] }
native-tls = "0.2.12"
percent-encoding = "2.3"
pin-project = "1.1"
# `default-features = false` drops reqwest 0.13's `default-tls = ["rustls"]`; we select
# `native-tls` (OpenSSL) to stay on the same TLS stack as ldap3 (see utils/tls.rs). The
Expand Down
3 changes: 3 additions & 0 deletions rust/info-fetcher-commons/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ serde_json.workspace = true
snafu.workspace = true
tracing.workspace = true
tokio.workspace = true
url.workspace = true

[dev-dependencies]
futures.workspace = true
percent-encoding.workspace = true
rstest.workspace = true
1 change: 1 addition & 0 deletions rust/info-fetcher-commons/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pub mod http;
pub mod secret;
pub mod tls;
pub mod token;
pub mod url_path;
133 changes: 133 additions & 0 deletions rust/info-fetcher-commons/src/utils/url_path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
use snafu::Snafu;
use url::Url;

#[derive(Snafu, Debug)]
pub enum Error {
#[snafu(display("unable to use {segment:?} as a URL path segment"))]
InvalidPathSegment { segment: String },

#[snafu(display("unable to append path segments to {url:?}, as it cannot be a base"))]
CannotBeABase { url: Url },
}

/// Returns `base` with each of `segments` appended as exactly one path segment.
///
/// Use this rather than [`Url::join`] or [`Url::set_path`] to create URLs from user/untrusted input.
/// Both those methods follow the specs but are easy to use insecurely because e.g. if you provide
/// an absolute URL they replace the host from `base` and a few other things (`../` treatment).
///
/// Fails on an empty, `.` or `..` segment and on a segment containing a tab or line break.
pub fn append_path_segments(base: &Url, segments: &[&str]) -> Result<Url, Error> {
let invalid_segment = segments.iter().find(|segment| {
matches!(**segment, "" | "." | "..") || segment.contains(['\t', '\n', '\r'])
});
if let Some(segment) = invalid_segment {
return InvalidPathSegmentSnafu { segment: *segment }.fail();
}

let mut url = base.clone();
url.path_segments_mut()
.map_err(|()| CannotBeABaseSnafu { url: base.clone() }.build())?
.pop_if_empty()
.extend(segments);

Ok(url)
}

#[cfg(test)]
mod tests {
use percent_encoding::percent_decode_str;
use rstest::rstest;

use super::*;

#[rstest]
#[case::root("http://keycloak:8080", "http://keycloak:8080/realms/my-realm")]
#[case::root_with_slash("http://keycloak:8080/", "http://keycloak:8080/realms/my-realm")]
#[case::root_path(
"http://keycloak:8080/auth",
"http://keycloak:8080/auth/realms/my-realm"
)]
#[case::root_path_with_slash(
"http://keycloak:8080/auth/",
"http://keycloak:8080/auth/realms/my-realm"
)]
fn append_keeps_the_base_path(#[case] base: &str, #[case] expected: &str) {
let base = Url::parse(base).expect("valid base URL");
let url = append_path_segments(&base, &["realms", "my-realm"]).expect("valid segments");

assert_eq!(url.as_str(), expected);
}

#[rstest]
#[case::absolute_url("https://evil.example.com/", "https:%2F%2Fevil.example.com%2F")]
#[case::protocol_relative("//evil.example.com", "%2F%2Fevil.example.com")]
#[case::traversal("../../realms/master", "..%2F..%2Frealms%2Fmaster")]
#[case::percent_encoded_dots("%2e%2e", "%252e%252e")]
#[case::backslash("..\\admin", "..%5Cadmin")]
#[case::query("alice?exact=false", "alice%3Fexact=false")]
#[case::fragment("alice#", "alice%23")]
#[case::guest_principal_name(
"alice_example.com#EXT#@contoso.com",
"alice_example.com%23EXT%23@contoso.com"
)]
fn append_encodes_a_hostile_segment(#[case] segment: &str, #[case] expected: &str) {
let base = Url::parse("http://keycloak:8080/users").expect("valid base URL");
let url = append_path_segments(&base, &[segment]).expect("valid segment");

assert_eq!(
url.as_str(),
format!("http://keycloak:8080/users/{expected}")
);
}

/// The URL is sent as a string and parsed again by the server, so every character has to stay
/// inside its segment after a round trip, not only in the [`Url`] built here.
#[test]
fn append_keeps_every_character_inside_its_segment() {
let base = Url::parse("http://keycloak:8080/users").expect("valid base URL");
let characters = (' '..='~').chain(['\0', '\x1b', '\x7f', 'ä', '€', '🦀']);

for character in characters {
let segment = format!("a{character}b");
let url = append_path_segments(&base, &[&segment]).expect("valid segment");
let sent = Url::parse(url.as_str()).expect("the built URL parses");

assert_eq!(sent, url, "{segment:?} changes in a round trip");
assert_eq!(sent.host_str(), Some("keycloak"), "{segment:?}");
assert_eq!(sent.query(), None, "{segment:?}");
assert_eq!(sent.fragment(), None, "{segment:?}");
let sent_segments: Vec<&str> = sent.path_segments().into_iter().flatten().collect();
let [_, sent_segment] = sent_segments.as_slice() else {
panic!("{segment:?} splits the path into {sent_segments:?}");
};
assert_eq!(
percent_decode_str(sent_segment).decode_utf8_lossy(),
segment,
"{segment:?} changes on its way to the server"
);
}
}

#[rstest]
#[case::empty("")]
#[case::current(".")]
#[case::parent("..")]
#[case::tab("ad\tmin")]
#[case::line_feed("ad\nmin")]
#[case::carriage_return("ad\rmin")]
fn append_rejects_a_segment_the_url_would_change(#[case] segment: &str) {
let base = Url::parse("http://keycloak:8080/users").expect("valid base URL");
let error = append_path_segments(&base, &["admin", segment]).expect_err("rejected");

assert!(matches!(error, Error::InvalidPathSegment { .. }), "{error}");
}

#[test]
fn append_rejects_a_base_without_a_path() {
let base = Url::parse("mailto:admin@example.com").expect("valid base URL");
let error = append_path_segments(&base, &["users"]).expect_err("rejected");

assert!(matches!(error, Error::CannotBeABase { .. }), "{error}");
}
}
1 change: 1 addition & 0 deletions rust/user-info-fetcher/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ url.workspace = true
uuid.workspace = true

[dev-dependencies]
percent-encoding.workspace = true
wiremock.workspace = true

[build-dependencies]
Expand Down
Loading
Loading