fix: reject plain HTTP repository sources

This commit is contained in:
2026-09-13 01:12:01 +09:00
10 changed files with 270 additions and 57 deletions
+38 -6
View File
@@ -288,13 +288,12 @@ impl RepositoryRegistryReader {
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
let mut diagnostics = Vec::new();
if repository.source.kind == workspace_api::RepositorySourceKind::Http {
if crate::repository_source::is_plain_http_repository_source(&repository.source) {
diagnostics.push(RepositoryDiagnostic {
severity: "warning".to_string(),
code: "repository_source_insecure_http".to_string(),
message:
"HTTP Repository source is unencrypted; prefer HTTPS or SSH when available."
.to_string(),
severity: "error".to_string(),
code: "repository_source_plain_http_unsupported".to_string(),
message: "Plain HTTP Repository sources are not executable; register an HTTPS or SSH source instead."
.to_string(),
});
}
let git = match repository.provider.as_str() {
@@ -607,6 +606,39 @@ mod tests {
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
}
#[test]
fn legacy_plain_http_source_is_projected_as_non_executable_error() {
let source: RepositorySource = serde_json::from_value(serde_json::json!({
"kind": "http",
"uri": "http://git.example.test/team/project.git",
}))
.unwrap();
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
id: "legacy-http".into(),
repository_key: "legacy-http".into(),
provider: "git".into(),
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
source,
source_revision: 1,
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
path: None,
default_selector: Some("main".into()),
}]);
let projection = reader.list();
assert_eq!(
projection.items[0].source.kind,
workspace_api::RepositorySourceKind::Invalid
);
let diagnostics = projection.items[0].diagnostics.as_ref().unwrap();
assert!(diagnostics.iter().any(|diagnostic| {
diagnostic.severity == "error"
&& diagnostic.code == "repository_source_plain_http_unsupported"
&& diagnostic.message.contains("HTTPS or SSH")
}));
}
#[test]
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
let source = RepositorySource {
@@ -136,6 +136,18 @@ pub fn project_repository_access_state(
)
}
fn validate_repository_access_source(
repository_key: &str,
source: &workspace_api::RepositorySource,
) -> Result<()> {
if crate::repository_source::is_plain_http_repository_source(source) {
return Err(Error::InvalidInput(format!(
"repository_source_plain_http_unsupported: Repository `{repository_key}` uses unsupported plain HTTP; register an HTTPS or SSH source instead"
)));
}
Ok(())
}
pub(crate) fn repository_ssh_endpoint(
repository_key: &str,
repository_uri: &str,
@@ -200,6 +212,7 @@ fn project_repository_access_evaluation(
let repository = store
.get_repository_by_key(workspace_id, &repository_key)?
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?;
validate_repository_access_source(&repository_key, &repository.source)?;
if repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
return Err(Error::InvalidInput(format!(
"Repository `{repository_key}` is not an ssh:// Repository"
@@ -1956,6 +1969,30 @@ mod tests {
assert!(!contribution.source.contains("secret_ref"));
}
#[test]
fn workspace_config_projection_rejects_legacy_plain_http_repository_source() {
let source: RepositorySource = serde_json::from_value(serde_json::json!({
"kind": "http",
"uri": "http://git.example.test/team/project.git",
}))
.unwrap();
let error = validate_repository_access_source("remote", &source).unwrap_err();
assert!(error.to_string().contains("unsupported plain HTTP"));
assert!(error.to_string().contains("HTTPS or SSH"));
let mismatched = workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Https,
uri: "http://git.example.test/team/project.git".to_string(),
};
let error = validate_repository_access_source("remote", &mismatched).unwrap_err();
assert!(
error
.to_string()
.contains("repository_source_plain_http_unsupported")
);
}
#[test]
fn master_key_is_external_and_stable() {
let dir = tempfile::tempdir().unwrap();
@@ -76,18 +76,19 @@ pub fn parse_repository_source(value: &str) -> Result<RepositorySource> {
require_remote_host_and_path(&parsed)?;
RepositorySourceKind::Ssh
}
"http" | "https" => {
"http" => {
return Err(Error::InvalidInput(
"repository_source_plain_http_unsupported: plain HTTP Repository sources are not supported; use HTTPS or SSH".to_string(),
));
}
"https" => {
if !parsed.username().is_empty() {
return Err(Error::InvalidInput(
"HTTP repository URI must not contain user information".to_string(),
"HTTPS repository URI must not contain user information".to_string(),
));
}
require_remote_host_and_path(&parsed)?;
if parsed.scheme() == "http" {
RepositorySourceKind::Http
} else {
RepositorySourceKind::Https
}
RepositorySourceKind::Https
}
scheme => {
return Err(Error::InvalidInput(format!(
@@ -111,6 +112,10 @@ pub fn classify_legacy_repository_source(value: &str) -> RepositorySource {
})
}
pub(crate) fn is_plain_http_repository_source(source: &RepositorySource) -> bool {
Url::parse(&source.uri).is_ok_and(|url| url.scheme() == "http")
}
pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
let payload = serde_json::to_vec(source).expect("Repository source serializes");
let mut hasher = Sha256::new();
@@ -172,7 +177,7 @@ mod tests {
use super::*;
#[test]
fn parses_local_file_ssh_http_and_https_sources_without_io() {
fn parses_local_file_ssh_and_https_sources_without_io() {
let cases = [
("/runtime/repos/project", RepositorySourceKind::LocalPath),
("file:///runtime/repos/project", RepositorySourceKind::File),
@@ -184,10 +189,6 @@ mod tests {
"git@example.test:org/project.git",
RepositorySourceKind::Ssh,
),
(
"http://git.test/org/project.git",
RepositorySourceKind::Http,
),
(
"https://git.test/org/project.git",
RepositorySourceKind::Https,
@@ -198,6 +199,26 @@ mod tests {
}
}
#[test]
fn rejects_plain_http_with_secure_transport_guidance() {
for source in [
"http://git.test/org/project.git",
"http://localhost/org/project.git",
"http://127.0.0.1/org/project.git",
] {
let error = parse_repository_source(source).expect_err("plain HTTP must fail closed");
assert!(error.to_string().contains("plain HTTP Repository sources"));
assert!(error.to_string().contains("HTTPS or SSH"));
}
}
#[test]
fn legacy_plain_http_is_preserved_only_as_invalid_evidence() {
let source = classify_legacy_repository_source("http://git.test/org/project.git");
assert_eq!(source.kind, RepositorySourceKind::Invalid);
assert_eq!(source.uri, "http://git.test/org/project.git");
}
#[test]
fn rejects_relative_unsupported_and_credential_bearing_sources() {
for source in [
+17 -3
View File
@@ -364,7 +364,6 @@ fn repository_local_path(source: &workspace_api::RepositorySource) -> Option<Pat
.ok()
.and_then(|uri| uri.to_file_path().ok()),
workspace_api::RepositorySourceKind::Ssh
| workspace_api::RepositorySourceKind::Http
| workspace_api::RepositorySourceKind::Https
| workspace_api::RepositorySourceKind::Invalid => None,
}
@@ -10818,9 +10817,8 @@ async fn create_workspace_working_directory(
"local_path" => workspace_api::RepositorySourceKind::LocalPath,
"file" => workspace_api::RepositorySourceKind::File,
"https" => workspace_api::RepositorySourceKind::Https,
"http" => workspace_api::RepositorySourceKind::Http,
"http" | "invalid" => workspace_api::RepositorySourceKind::Invalid,
"ssh" => workspace_api::RepositorySourceKind::Ssh,
"invalid" => workspace_api::RepositorySourceKind::Invalid,
_ => {
return Err(settings_bad_request(
"working_directory_repository_source_invalid",
@@ -22275,6 +22273,22 @@ mod tests {
.unwrap();
assert_eq!(replayed.status(), StatusCode::OK);
let plain_http = app
.clone()
.oneshot(create_repository(serde_json::json!({
"repository_key": "insecure-http",
"source": "http://git.example.test/team/project.git",
"default_ref": "main"
})))
.await
.unwrap();
assert_eq!(plain_http.status(), StatusCode::BAD_REQUEST);
let plain_http_body = to_bytes(plain_http.into_body(), usize::MAX).await.unwrap();
let plain_http_body = String::from_utf8_lossy(&plain_http_body);
assert!(plain_http_body.contains("repository_source_plain_http_unsupported"));
assert!(plain_http_body.contains("plain HTTP Repository sources"));
assert!(plain_http_body.contains("HTTPS or SSH"));
let conflict = app
.clone()
.oneshot(create_repository(serde_json::json!({