Selaa lähdekoodia

web token: viewer links in the embedded recipes panel carry the auth token (query fallback + localStorage) — bare /r/ links 401'd in token mode

Alexander Korolev 2 viikkoa sitten
vanhempi
sitoutus
b9da59a70f
3 muutettua tiedostoa jossa 51 lisäystä ja 4 poistoa
  1. 3 3
      src/report/page.html
  2. 10 1
      src/report/ui_tests.rs
  3. 38 0
      src/web/tests.rs

+ 3 - 3
src/report/page.html

@@ -259,7 +259,7 @@ function renderNotifs() {
     const when = n.date ? `${n.date}${n.time ? "·" + n.time : ""}` : "";
     const score = n.score != null ? `${n.score} /100` : n.points || "";
     const links = n.files.map(f =>
-      `<a class="open" target="_blank" href="/r/${encodeURIComponent(n.name)}/${encodeURIComponent(f)}" title="open ${esc(f)}">${esc(f.split(".").pop())}</a>`).join(" ");
+      `<a class="open" target="_blank" href="${auth("/r/" + encodeURIComponent(n.name) + "/" + encodeURIComponent(f))}" title="open ${esc(f)}">${esc(f.split(".").pop())}</a>`).join(" ");
     return `<div class="noti" data-name="${esc(n.name)}" title="show in table">
       <span class="nic">${NICON[n.t] || "•"}</span>
       <div><b>${esc(n.name)}</b> <span class="dim">${esc(n.label)}</span><br>
@@ -487,7 +487,7 @@ function render() {
     if (S.openRow === r.name) {
       // one toml section per recipe (the recipe.toml is not versioned);
       // per-run report files stay on each line
-      const toml = `<a class="open" href="/r/${encodeURIComponent(r.name)}/recipe.toml" target="_blank" title="recipe.toml (highlighted)">⚙ toml</a>`;
+      const toml = `<a class="open" href="${auth("/r/" + encodeURIComponent(r.name) + "/recipe.toml")}" target="_blank" title="recipe.toml (highlighted)">⚙ toml</a>`;
       const lines = r.reports.map(e => {
         const fl = e.files && e.files.length ? e.files : [e.file];
         const ts = (e.perf && e.perf.length)
@@ -500,7 +500,7 @@ function render() {
           ? `<span class="prov" title="recipe ${esc(pr.recipe_sha)}${pr.recipe_drift ? " (CHANGED SINCE THIS RUN — see the run's recipe.toml copy)" : ""} · image ${esc(pr.image || "—")} @ ${esc(pr.image_sum || "—")} (docker id ${esc(pr.image_id || "—")}) · tool ${esc(pr.tool || "—")} · launcher ${esc(pr.launcher || "—")}">⚙ ${esc((pr.image_sum || "–").slice(0, 12))}·${esc(pr.recipe_sha || "–")}${pr.recipe_drift ? "⚠" : ""}</span> `
           : "";
         return `<span>${fl.map(f =>
-          `<a class="open" href="/r/${encodeURIComponent(r.name)}/${encodeURIComponent(f)}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
+          `<a class="open" href="${auth("/r/" + encodeURIComponent(r.name) + "/" + encodeURIComponent(f))}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
         `${esc(e.date)}${e.time ? "·" + esc(e.time) : ""} ${esc(e.label)} — ${e.pct != null ? esc(e.pct) + "/100" : '<span class="dim">no score</span>'}${e.points ? " (" + esc(e.points) + ")" : ""}${ts}${chip}`;
       }).join("<br>");
       out.push(`<tr class="hist"><td colspan="${vis.length}">${toml}${r.reports.length ? " " + lines : ' <span class="dim">no reports</span>'}</td></tr>`);

+ 10 - 1
src/report/ui_tests.rs

@@ -198,7 +198,7 @@ fn html_page_at_rewrites_own_paths() {
     );
     assert!(!page.contains("auth(\"/events\")"));
     assert!(
-        page.contains("href=\"/recipes/r/"),
+        page.contains("auth(\"/recipes/r/"),
         "viewer link not rewritten"
     );
     assert!(!page.contains("href=\"/r/"));
@@ -215,6 +215,15 @@ fn html_page_at_rewrites_own_paths() {
         super::html_page(&[]).contains("<body>"),
         "standalone page keeps its unmarked body"
     );
+    // viewer links carry the auth token (the token gate rejects bare /r/)
+    assert!(
+        page.contains("auth(\"/recipes/r/\""),
+        "viewer link not auth-wrapped"
+    );
+    assert!(
+        !page.contains("href=\"/r/\""),
+        "unwrapped viewer link remains"
+    );
 }
 
 #[test]

+ 38 - 0
src/web/tests.rs

@@ -161,6 +161,44 @@ async fn token_gate_enforces_when_set() {
     );
 }
 
+#[tokio::test]
+async fn token_gate_covers_recipes_panel() {
+    let app = app_state(Some("s3cret"));
+    *app.last.lock().unwrap() = Some(crate::web::events::Snapshot {
+        ts: 1,
+        clusters: Vec::new(),
+        jobs: Vec::new(),
+        topo: Default::default(),
+        attaches: Vec::new(),
+    });
+    let (addr, handle) = boot_app(app).await;
+    let base = format!("http://{addr}");
+    // bare requests are rejected on every panel route...
+    for path in ["/recipes", "/recipes/events"] {
+        let st = reqwest::get(format!("{base}{path}"))
+            .await
+            .unwrap()
+            .status();
+        assert_eq!(st, 401, "{path} must be gated");
+    }
+    // ...and the token (query form, what the iframe and SSE use) opens them
+    let page = reqwest::get(format!("{base}/recipes?token=s3cret"))
+        .await
+        .unwrap()
+        .text()
+        .await
+        .unwrap();
+    assert!(
+        page.contains("auth(\"/recipes/events\")"),
+        "page wires the token"
+    );
+    let sse = reqwest::get(format!("{base}/recipes/events?token=s3cret"))
+        .await
+        .unwrap();
+    assert_eq!(sse.status(), 200);
+    handle.abort();
+}
+
 #[tokio::test]
 async fn sse_replays_cached_snapshot_on_connect() {
     use tokio::io::{AsyncReadExt, AsyncWriteExt};