fix(web): accepte GET /api/project-files* sans Origin (allow_missing ciblé) — #214
This commit is contained in:
@ -1241,7 +1241,7 @@ async fn validate_ws_upgrade(
|
||||
) -> Result<(String, AuthenticatedDevice), Box<Response<ResponseBody>>> {
|
||||
validate_reverse_proxy(headers, peer_ip, state, WS_PATH)?;
|
||||
|
||||
let origin = match validate_request_origin(headers, &state.config) {
|
||||
let origin = match validate_request_origin(headers, &state.config, false) {
|
||||
Ok(origin) => origin,
|
||||
Err(response) => {
|
||||
state.log_security(SecurityLogEvent::OriginRejected {
|
||||
@ -1800,7 +1800,11 @@ async fn dispatch_http(
|
||||
return *response;
|
||||
}
|
||||
|
||||
let origin = match validate_request_origin(&headers, &state.config) {
|
||||
let origin = match validate_request_origin(
|
||||
&headers,
|
||||
&state.config,
|
||||
api_route_allows_missing_origin(&method, uri.path()),
|
||||
) {
|
||||
Ok(origin) => origin,
|
||||
Err(response) => {
|
||||
state.log_security(SecurityLogEvent::OriginRejected {
|
||||
@ -4954,8 +4958,12 @@ where
|
||||
fn validate_request_origin(
|
||||
headers: &HeaderMap,
|
||||
config: &ServerConfig,
|
||||
allow_missing: bool,
|
||||
) -> Result<Option<String>, Box<Response<ResponseBody>>> {
|
||||
let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else {
|
||||
if allow_missing {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(Box::new(error_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"FORBIDDEN",
|
||||
@ -4976,6 +4984,16 @@ fn validate_request_origin(
|
||||
}
|
||||
}
|
||||
|
||||
fn api_route_allows_missing_origin(method: &Method, path: &str) -> bool {
|
||||
method == Method::GET
|
||||
&& matches!(
|
||||
path,
|
||||
"/api/project-files"
|
||||
| "/api/project-files/download-file"
|
||||
| "/api/project-files/download-directory"
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_reverse_proxy(
|
||||
headers: &HeaderMap,
|
||||
peer_ip: Option<IpAddr>,
|
||||
@ -6052,6 +6070,29 @@ mod tests {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn request_without_origin(
|
||||
state: Arc<ServerState>,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
body: Value,
|
||||
extra_headers: &[(&str, &str)],
|
||||
) -> Response<ResponseBody> {
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(CONTENT_TYPE, "application/json");
|
||||
for (name, value) in extra_headers {
|
||||
builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value);
|
||||
}
|
||||
handle_request(
|
||||
builder
|
||||
.body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap())))
|
||||
.unwrap(),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn response_json(response: Response<ResponseBody>) -> (StatusCode, Value, HeaderMap) {
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
@ -6570,6 +6611,20 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_upgrade_requires_origin_header() {
|
||||
let state = state();
|
||||
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||
let mut headers = ws_headers("http://127.0.0.1:17373", Some(&cookie));
|
||||
headers.remove(ORIGIN);
|
||||
|
||||
let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_upgrade_accepts_valid_cookie_and_origin() {
|
||||
let state = state();
|
||||
@ -7309,6 +7364,26 @@ mod tests {
|
||||
assert!(without_proxy.validate().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_pair_requires_origin_header() {
|
||||
let state = state();
|
||||
|
||||
let (status, body, _) = response_json(
|
||||
request_without_origin(
|
||||
state,
|
||||
Method::POST,
|
||||
"/api/pair",
|
||||
json!({ "code": "PAIR1234", "name": "Test device" }),
|
||||
&[],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["message"], "missing Origin header");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_bind_rejects_peer_when_no_authorized_proxy_is_configured() {
|
||||
let logger = Arc::new(RecordingSecurityLogger::default());
|
||||
@ -8541,6 +8616,90 @@ mod tests {
|
||||
assert!(!entries.iter().any(|entry| entry["name"] == ".ideai"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorized_project_file_gets_allow_missing_origin_header() {
|
||||
let state = state();
|
||||
let project_id = create_project_for_test(&state, "Project Files No Origin").await;
|
||||
let project = resolve_project_readonly(&project_id, &state.app)
|
||||
.await
|
||||
.expect("project resolves");
|
||||
std::fs::create_dir_all(format!("{}/src", project.root.as_str())).unwrap();
|
||||
std::fs::write(
|
||||
format!("{}/src/main.rs", project.root.as_str()),
|
||||
"fn main() {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||
|
||||
let (list_status, list_body, _) = response_json(
|
||||
request_without_origin(
|
||||
Arc::clone(&state),
|
||||
Method::GET,
|
||||
&format!("/api/project-files?projectId={project_id}&path="),
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
let (file_status, file_body, _) = response_bytes(
|
||||
request_without_origin(
|
||||
Arc::clone(&state),
|
||||
Method::GET,
|
||||
&format!(
|
||||
"/api/project-files/download-file?projectId={project_id}&path=src/main.rs"
|
||||
),
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
let (directory_status, directory_body, _) = response_bytes(
|
||||
request_without_origin(
|
||||
Arc::clone(&state),
|
||||
Method::GET,
|
||||
&format!("/api/project-files/download-directory?projectId={project_id}&path=src"),
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(list_status, StatusCode::OK);
|
||||
assert!(list_body["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| entry["path"] == "src"));
|
||||
assert_eq!(file_status, StatusCode::OK);
|
||||
assert_eq!(file_body, Bytes::from_static(b"fn main() {}\n"));
|
||||
assert_eq!(directory_status, StatusCode::OK);
|
||||
assert!(directory_body.starts_with(b"PK"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_get_project_file_route_still_requires_origin_header() {
|
||||
let state = state();
|
||||
let project_id = create_project_for_test(&state, "Project Files POST No Origin").await;
|
||||
|
||||
let (status, body, _) = response_json(
|
||||
request_without_origin(
|
||||
state,
|
||||
Method::POST,
|
||||
&format!("/api/project-files?projectId={project_id}&path="),
|
||||
json!({}),
|
||||
&[],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["message"], "missing Origin header");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorized_project_files_rejects_traversal_and_hidden_paths() {
|
||||
let state = state();
|
||||
|
||||
Reference in New Issue
Block a user