Skip to content
Merged
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
110 changes: 103 additions & 7 deletions crates/jirakeep-core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,12 +264,38 @@ impl JiraClient {
}

/// Download attachment content by content URL (absolute or relative).
///
/// Relative URLs resolve against the configured base URL. An absolute URL
/// is honored only when its origin (scheme + host + port) equals the base
/// URL's origin; any other target is refused with a generic error before
/// any request is made, so the caller's credentials are only ever sent to
/// the operator's configured Jira origin (I12 spirit: token custody).
pub async fn download_attachment_bytes(
&self,
creds: &Credentials,
content_url: &str,
) -> Result<Vec<u8>> {
let url = if content_url.starts_with("http://") || content_url.starts_with("https://") {
let url = self.same_origin_content_url(content_url)?;
let rb = self.apply_auth(self.http.get(url), creds);
let resp = rb.send().await.map_err(sanitize)?;
let status = resp.status();
if !status.is_success() {
bail!("jira attachment download error (HTTP {})", status.as_u16());
}
resp.bytes().await.map(|b| b.to_vec()).map_err(sanitize)
}

/// Resolve an attachment content URL and enforce that it stays on the
/// configured base URL's origin (scheme + host + port). Fails closed with
/// a generic error that reveals neither the refused target nor anything
/// about issue or attachment existence.
fn same_origin_content_url(&self, content_url: &str) -> Result<reqwest::Url> {
// Deliberately generic and uniform with other download failures: the
// text must not disclose the target or issue/attachment existence.
const REFUSED: &str = "jira attachment download error";
let base = reqwest::Url::parse(&self.base_url).map_err(|_| anyhow!(REFUSED))?;
let candidate = if content_url.starts_with("http://") || content_url.starts_with("https://")
{
content_url.to_string()
} else {
format!(
Expand All @@ -282,13 +308,11 @@ impl JiraClient {
}
)
};
let rb = self.apply_auth(self.http.get(&url), creds);
let resp = rb.send().await.map_err(sanitize)?;
let status = resp.status();
if !status.is_success() {
bail!("jira attachment download error (HTTP {})", status.as_u16());
let url = reqwest::Url::parse(&candidate).map_err(|_| anyhow!(REFUSED))?;
if url.origin() != base.origin() {
bail!(REFUSED);
}
resp.bytes().await.map(|b| b.to_vec()).map_err(sanitize)
Ok(url)
}

/// GET transitions available for an issue.
Expand Down Expand Up @@ -580,6 +604,78 @@ mod tests {
assert!(JiraClient::new("https://example.atlassian.net", " ").is_err());
}

fn https_client() -> JiraClient {
JiraClient::new("https://example.atlassian.net", "jirakeep/0.0.0 (+test)")
.expect("client builds")
}

#[test]
fn content_url_relative_resolves_against_base() {
let c = https_client();
let url = c
.same_origin_content_url("/secure/attachment/10001/notes.txt")
.expect("relative content URL resolves");
assert_eq!(
url.as_str(),
"https://example.atlassian.net/secure/attachment/10001/notes.txt"
);
let url = c
.same_origin_content_url("secure/attachment/10001/notes.txt")
.expect("relative content URL without leading slash resolves");
assert_eq!(
url.as_str(),
"https://example.atlassian.net/secure/attachment/10001/notes.txt"
);
}

#[test]
fn content_url_same_origin_absolute_is_allowed() {
let c = https_client();
let url = c
.same_origin_content_url("https://example.atlassian.net/secure/attachment/1/a.txt")
.expect("same-origin absolute content URL is allowed");
assert_eq!(url.host_str(), Some("example.atlassian.net"));
}

#[test]
fn content_url_default_port_matches_explicit_port() {
let c = JiraClient::new(
"https://example.atlassian.net:443",
"jirakeep/0.0.0 (+test)",
)
.expect("client builds");
assert!(c
.same_origin_content_url("https://example.atlassian.net/secure/attachment/1/a.txt")
.is_ok());
}

#[test]
fn content_url_foreign_host_is_refused_generically() {
let c = https_client();
let err = c
.same_origin_content_url("https://attacker.example/collect")
.expect_err("foreign host must be refused");
let msg = format!("{err:#}");
assert_eq!(msg, "jira attachment download error");
assert!(!msg.contains("attacker.example"), "target leaked: {msg}");
}

#[test]
fn content_url_http_downgrade_is_refused_when_base_is_https() {
let c = https_client();
assert!(c
.same_origin_content_url("http://example.atlassian.net/secure/attachment/1/a.txt")
.is_err());
}

#[test]
fn content_url_other_port_is_refused() {
let c = https_client();
assert!(c
.same_origin_content_url("https://example.atlassian.net:8443/secure/attachment/1/a.txt")
.is_err());
}

#[test]
fn plain_to_adf_wraps_text() {
let v = plain_to_adf("hello");
Expand Down
58 changes: 57 additions & 1 deletion crates/jirakeep-core/tests/client_wiremock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use jirakeep_core::client::{AuthMode, Credentials, JiraClient};
use serde_json::json;
use wiremock::matchers::{header, method, path, path_regex};
use wiremock::matchers::{any, header, method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

const TOKEN: &str = "SUPERSECRETTOKEN123";
Expand Down Expand Up @@ -138,6 +138,62 @@ async fn add_comment() {
assert_eq!(out["id"], json!("100"));
}

#[tokio::test]
async fn download_attachment_relative_and_same_origin_urls() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/secure/attachment/10001/notes.txt"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
.mount(&server)
.await;

let c = basic_client(&server);
// A relative content URL resolves against the (here: http) base URL.
let bytes = c
.download_attachment_bytes(&creds(), "/secure/attachment/10001/notes.txt")
.await
.expect("relative download");
assert_eq!(bytes, b"hello");
// An absolute content URL on the same origin is honored too.
let absolute = format!("{}/secure/attachment/10001/notes.txt", server.uri());
let bytes = c
.download_attachment_bytes(&creds(), &absolute)
.await
.expect("same-origin absolute download");
assert_eq!(bytes, b"hello");
}

#[tokio::test]
async fn download_attachment_never_contacts_a_foreign_host() {
let jira = MockServer::start().await;
let foreign = MockServer::start().await;
// Any request reaching the foreign host — credentialed or not — fails
// the test on drop via the expect(0) verification.
Mock::given(any())
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"stolen".to_vec()))
.expect(0)
.mount(&foreign)
.await;

let c = basic_client(&jira);
let off_origin = format!("{}/collect", foreign.uri());
let err = c
.download_attachment_bytes(&creds(), &off_origin)
.await
.expect_err("off-origin content URL must be refused");
let msg = format!("{err:#}");
assert!(!msg.contains(TOKEN), "token leaked into error: {msg}");
assert!(
!msg.contains(&foreign.uri()),
"refusal echoed the foreign URL: {msg}"
);
let received = foreign.received_requests().await.unwrap_or_default();
assert!(
received.is_empty(),
"a request (and its Authorization header) reached the foreign host"
);
}

#[tokio::test]
async fn bearer_auth_sends_authorization_header() {
let server = MockServer::start().await;
Expand Down