merge(web): accès fichiers sans Origin header — #214 (QA verte)
This commit is contained in:
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#212"
|
issueRef: "#212"
|
||||||
version: 5
|
version: 6
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
updatedAt: 1786786758650
|
updatedAt: 1786786870793
|
||||||
---
|
---
|
||||||
2026-08-15 Main triage + implementation + QA.
|
2026-08-15 Main triage + implementation + QA.
|
||||||
- Symptome confirme par lecture code: frontend web invoque `load_layout`, mais `crates/web-server/src/lib.rs` ne route pas cette commande et tombe sur `UNKNOWN_COMMAND`.
|
- Symptome confirme par lecture code: frontend web invoque `load_layout`, mais `crates/web-server/src/lib.rs` ne route pas cette commande et tombe sur `UNKNOWN_COMMAND`.
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
id: "1dbe01ec-94f4-404d-9ffb-2ceeceb0557b"
|
id: "1dbe01ec-94f4-404d-9ffb-2ceeceb0557b"
|
||||||
number: 212
|
number: 212
|
||||||
title: "Web PC: erreur `unknown command: load_layout` a l'ouverture d'un projet"
|
title: "Web PC: erreur `unknown command: load_layout` a l'ouverture d'un projet"
|
||||||
status: "qa"
|
status: "closed"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: []
|
links: []
|
||||||
@ -11,7 +11,7 @@ attachments: []
|
|||||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
createdAt: 1786786004974
|
createdAt: 1786786004974
|
||||||
updatedAt: 1786786758650
|
updatedAt: 1786786870793
|
||||||
version: 5
|
version: 6
|
||||||
---
|
---
|
||||||
A l'ouverture d'un projet dans l'interface web desktop/PC, l'application affiche `unknown command: load_layout`. Triage et correctif a produire avec cycle UX/Architect/Git/Dev/QA.
|
A l'ouverture d'un projet dans l'interface web desktop/PC, l'application affiche `unknown command: load_layout`. Triage et correctif a produire avec cycle UX/Architect/Git/Dev/QA.
|
||||||
13
.ideai/tickets/213/carnet.md
Normal file
13
.ideai/tickets/213/carnet.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#213"
|
||||||
|
version: 3
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedAt: 1786787447568
|
||||||
|
---
|
||||||
|
2026-08-15 rebuild AppImage.
|
||||||
|
- Skill `build-appimage` lu et applique.
|
||||||
|
- Branche/commit au lancement: `develop` @ `b3bf6fd2d64990639cef22fbb2a0e5f35579aabc`.
|
||||||
|
- Frontend build OK via `npm --prefix frontend run build`.
|
||||||
|
- Bundle AppImage OK via `CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage` depuis `crates/app-tauri/`.
|
||||||
|
- Artefact produit: `target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`.
|
||||||
|
- Etape restante: tagging Git du commit buildé selon la regle branche->version (`develop` => increment Y).
|
||||||
17
.ideai/tickets/213/issue.md
Normal file
17
.ideai/tickets/213/issue.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
id: "f0bfa4e0-8f1d-4269-a7f5-422090d7c79d"
|
||||||
|
number: 213
|
||||||
|
title: "Rebuild AppImage Linux"
|
||||||
|
status: "closed"
|
||||||
|
priority: "medium"
|
||||||
|
sprint: null
|
||||||
|
links: []
|
||||||
|
agentRefs: []
|
||||||
|
attachments: []
|
||||||
|
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
createdAt: 1786787271504
|
||||||
|
updatedAt: 1786787447568
|
||||||
|
version: 3
|
||||||
|
---
|
||||||
|
Reconstruction de l'AppImage IdeA Linux via le workflow valide, puis tagging Git de la version du commit buildé selon la branche courante.
|
||||||
29
.ideai/tickets/214/carnet.md
Normal file
29
.ideai/tickets/214/carnet.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#214"
|
||||||
|
version: 5
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedAt: 1786808846046
|
||||||
|
---
|
||||||
|
## Triage 2026-08-15
|
||||||
|
- Symptôme: sur l'interface web PC, l'accès aux fichiers échoue avec `missing Origin header`.
|
||||||
|
- Diagnostic convergent UX/Architect: rejet backend `403` sur validation `Origin` appliquée aux routes `/api/*`, y compris des `GET` fichiers browser-safe.
|
||||||
|
- Branche locale créée par Git: `fix/214-web-origin-header-file-access` depuis `develop`.
|
||||||
|
- Direction de fix: conserver le contrôle strict `Origin` pour `POST`/`OPTIONS`/WS, mais accepter les `GET` lecture/fichiers sans header `Origin` et ajouter des tests de non-régression.
|
||||||
|
- UX: si un message utilisateur reste nécessaire, ne pas exposer le texte technique brut `missing Origin header`.
|
||||||
|
|
||||||
|
## Implémentation DevBackend
|
||||||
|
- Fichier modifié: `crates/web-server/src/lib.rs`.
|
||||||
|
- Politique livrée: `GET /api/project-files`, `/api/project-files/download-file`, `/api/project-files/download-directory` acceptés sans `Origin`; validation stricte maintenue si `Origin` est présent.
|
||||||
|
- Non-régression attendue: `POST /api/pair` sans `Origin` toujours refusé, WebSocket sans `Origin` toujours refusé.
|
||||||
|
- Tests DevBackend:
|
||||||
|
- `cargo fmt --package web-server` => OK
|
||||||
|
- `cargo test -p web-server origin_header` => OK (3 tests)
|
||||||
|
- `cargo test -p web-server authorized_project_file_gets_allow_missing_origin_header` => OK
|
||||||
|
- `cargo test -p web-server` => KO (`Too many open files`)
|
||||||
|
- `cargo test -p web-server -- --test-threads=1` => échecs PTY/WebSocket non reliés au correctif Origin selon DevBackend
|
||||||
|
|
||||||
|
## Verdict QA
|
||||||
|
- QA a ajouté un test négatif complémentaire pour verrouiller qu'une route fichier non-GET sans `Origin` reste refusée.
|
||||||
|
- Commande validante principale: `cargo test -p web-server origin_header -- --nocapture` => VERT (4/4)
|
||||||
|
- Rejeu ciblé: `cargo test -p web-server non_get_project_file_route_still_requires_origin_header -- --nocapture` => VERT
|
||||||
|
- Conclusion: `GET /api/project-files*` sans `Origin` accepté; `POST /api/pair`, WebSocket et route fichier non-GET sans `Origin` refusés.
|
||||||
17
.ideai/tickets/214/issue.md
Normal file
17
.ideai/tickets/214/issue.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
id: "05b7dc82-f248-476d-8ae6-c5190b2d9d02"
|
||||||
|
number: 214
|
||||||
|
title: "Bug web PC: accès fichiers échoue avec 'missing Origin header'"
|
||||||
|
status: "qa"
|
||||||
|
priority: "high"
|
||||||
|
sprint: null
|
||||||
|
links: []
|
||||||
|
agentRefs: []
|
||||||
|
attachments: []
|
||||||
|
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
createdAt: 1786808303548
|
||||||
|
updatedAt: 1786808846046
|
||||||
|
version: 5
|
||||||
|
---
|
||||||
|
Sur l'interface web PC, l'accès aux fichiers échoue avec l'erreur 'missing Origin header'. Objectif: identifier la cause, corriger le flux web desktop concerné, puis valider par tests QA réels.
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 213
|
"nextNumber": 215
|
||||||
}
|
}
|
||||||
@ -2862,6 +2862,34 @@
|
|||||||
"issueRef": "#212",
|
"issueRef": "#212",
|
||||||
"path": "212",
|
"path": "212",
|
||||||
"title": "Web PC: erreur `unknown command: load_layout` a l'ouverture d'un projet",
|
"title": "Web PC: erreur `unknown command: load_layout` a l'ouverture d'un projet",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "high",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1786786870793
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#213",
|
||||||
|
"path": "213",
|
||||||
|
"title": "Rebuild AppImage Linux",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "medium",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1786787447568
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#214",
|
||||||
|
"path": "214",
|
||||||
|
"title": "Bug web PC: accès fichiers échoue avec 'missing Origin header'",
|
||||||
"status": "qa",
|
"status": "qa",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
@ -2870,7 +2898,7 @@
|
|||||||
"kind": "agent",
|
"kind": "agent",
|
||||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
},
|
},
|
||||||
"updatedAt": 1786786758650
|
"updatedAt": 1786808846046
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -1241,7 +1241,7 @@ async fn validate_ws_upgrade(
|
|||||||
) -> Result<(String, AuthenticatedDevice), Box<Response<ResponseBody>>> {
|
) -> Result<(String, AuthenticatedDevice), Box<Response<ResponseBody>>> {
|
||||||
validate_reverse_proxy(headers, peer_ip, state, WS_PATH)?;
|
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,
|
Ok(origin) => origin,
|
||||||
Err(response) => {
|
Err(response) => {
|
||||||
state.log_security(SecurityLogEvent::OriginRejected {
|
state.log_security(SecurityLogEvent::OriginRejected {
|
||||||
@ -1800,7 +1800,11 @@ async fn dispatch_http(
|
|||||||
return *response;
|
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,
|
Ok(origin) => origin,
|
||||||
Err(response) => {
|
Err(response) => {
|
||||||
state.log_security(SecurityLogEvent::OriginRejected {
|
state.log_security(SecurityLogEvent::OriginRejected {
|
||||||
@ -4954,8 +4958,12 @@ where
|
|||||||
fn validate_request_origin(
|
fn validate_request_origin(
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
config: &ServerConfig,
|
config: &ServerConfig,
|
||||||
|
allow_missing: bool,
|
||||||
) -> Result<Option<String>, Box<Response<ResponseBody>>> {
|
) -> Result<Option<String>, Box<Response<ResponseBody>>> {
|
||||||
let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else {
|
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(
|
return Err(Box::new(error_response(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"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(
|
fn validate_reverse_proxy(
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
peer_ip: Option<IpAddr>,
|
peer_ip: Option<IpAddr>,
|
||||||
@ -6052,6 +6070,29 @@ mod tests {
|
|||||||
.await
|
.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) {
|
async fn response_json(response: Response<ResponseBody>) -> (StatusCode, Value, HeaderMap) {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let headers = response.headers().clone();
|
let headers = response.headers().clone();
|
||||||
@ -6570,6 +6611,20 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
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]
|
#[tokio::test]
|
||||||
async fn websocket_upgrade_accepts_valid_cookie_and_origin() {
|
async fn websocket_upgrade_accepts_valid_cookie_and_origin() {
|
||||||
let state = state();
|
let state = state();
|
||||||
@ -7309,6 +7364,26 @@ mod tests {
|
|||||||
assert!(without_proxy.validate().is_err());
|
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]
|
#[tokio::test]
|
||||||
async fn remote_bind_rejects_peer_when_no_authorized_proxy_is_configured() {
|
async fn remote_bind_rejects_peer_when_no_authorized_proxy_is_configured() {
|
||||||
let logger = Arc::new(RecordingSecurityLogger::default());
|
let logger = Arc::new(RecordingSecurityLogger::default());
|
||||||
@ -8541,6 +8616,90 @@ mod tests {
|
|||||||
assert!(!entries.iter().any(|entry| entry["name"] == ".ideai"));
|
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]
|
#[tokio::test]
|
||||||
async fn authorized_project_files_rejects_traversal_and_hidden_paths() {
|
async fn authorized_project_files_rejects_traversal_and_hidden_paths() {
|
||||||
let state = state();
|
let state = state();
|
||||||
|
|||||||
Reference in New Issue
Block a user