package api import ( "context" "fmt" "io" "net" "net/http" "net/http/httptest" "os/exec" "strings" "sync/atomic" "testing" "git.misaka.ren/m1saka/dsh-web/internal/store" ) func TestRuntimeFreeSearchProxy(t *testing.T) { var forwarded atomic.Int32 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("token") == "launch" { http.SetCookie(w, &http.Cookie{Name: "runtime", Value: r.Host}) w.WriteHeader(303) return } forwarded.Add(1) cookie, err := r.Cookie("runtime") if err != nil || cookie.Value != r.Host { t.Error("Host-bound cookie mismatch", r.Host, cookie) } if r.Header.Get("Authorization") != "" || r.Header.Get("X-CSRF-Token") != "" || strings.Contains(r.Header.Get("Cookie"), "dsh_session") { t.Error("panel credentials leaked") } if runtimeFreeSearchPath(r.URL.Path) { remote, _, _ := net.SplitHostPort(r.RemoteAddr) if !net.ParseIP(remote).IsLoopback() || r.Host != "localhost" || r.Header.Get("Origin") != "http://localhost" { t.Error("loopback guard failed", r.Host, r.Header) } data, _ := io.ReadAll(r.Body) if string(data) != "payload" || r.URL.RawQuery != "keep=1" { t.Error("request changed", string(data), r.URL.RawQuery) } } else if r.Host != "public.example:8443" || r.Header.Get("Origin") != "https://public.example:8443" { t.Error("ordinary resource authority changed", r.Host) } w.WriteHeader(204) })) defer upstream.Close() old := runtimeTransport transport := old.Clone() transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { if address != "192.0.2.10:8080" { return nil, fmt.Errorf("not the Caddy target: %s", address) } return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://")) } runtimeTransport = transport defer func() { runtimeTransport = old; transport.CloseIdleConnections() }() a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/json") { fmt.Fprintf(w, `{"State":{"Running":true,"StartedAt":"1970-01-01T00:00:01Z"},"NetworkSettings":{"Networks":{%q:{"IPAddress":"192.0.2.10"}}}}`, runtimeNetwork("demo")) } else { io.WriteString(w, "http://localhost/?token=launch\n") } }) a.origin = "https://public.example:8443" defer runtimeAuthCache.Delete(runtimeAuthKey{a, "demo"}) for _, i := range []store.Instance{{ID: "demo", UID: "owner", ContainerID: "container"}, {ID: "foreign", UID: "other"}} { if err := a.s.AddInstance(i); err != nil { t.Fatal(err) } } call := func(method, path, origin, session, site string, want int) { t.Helper() r := runtimeRequest(method, path, "payload", session) r.Host = "public.example:8443" r.Header.Set("Origin", origin) r.Header.Set("Sec-Fetch-Site", site) r.Header.Set("Authorization", "Bearer panel-secret") r.Header.Set("X-CSRF-Token", "panel-secret") w := httptest.NewRecorder() a.Handler().ServeHTTP(w, r) if w.Code != want { t.Fatalf("%s %s origin=%q: %d %s", method, path, origin, w.Code, w.Body.String()) } } const bridge = "/api/dsh-free-search-settings/" for _, endpoint := range []string{"describe", "mutate", "credentials-status", "credentials-set", "credentials-unset", "check-update", "update", "raw-search"} { call("POST", "/dsh/demo"+bridge+endpoint+"?keep=1&token=browser", a.origin, sid, "same-origin", 204) } for _, path := range []string{"/assets/client.js", bridge + "unknown", bridge + "describe/", bridge + "describe-extra", "/api/dsh-free-search-settings-evil/describe"} { call("POST", "/dsh/demo"+path, a.origin, sid, "same-origin", 204) } call("POST", "/dsh/demo"+bridge+"describe?keep=1", a.origin, sid, "same-origin", 204) count := forwarded.Load() for _, origin := range []string{"", "null", "https://evil.example", "http://localhost", "https://public.example:8443.evil"} { call("POST", "/dsh/demo"+bridge+"mutate", origin, sid, "", 403) } call("POST", "/dsh/demo"+bridge+"mutate", a.origin, sid, "cross-site", 403) call("POST", "/dsh/demo"+bridge+"mutate", a.origin, "", "", 401) call("POST", "/dsh/foreign"+bridge+"mutate", a.origin, sid, "", 404) call("GET", "/dsh/demo"+bridge+"mutate", "", sid, "", 405) call("POST", bridge+"mutate", a.origin, "", "", 403) call("GET", bridge+"describe", "", "", "", 404) if forwarded.Load() != count { t.Fatal("denied request reached runtime") } } func TestRuntimeFreeSearchFetchSemantics(t *testing.T) { node, err := exec.LookPath("node") if err != nil { t.Skip("Node required for Fetch API semantics") } script := `const assert = require("node:assert/strict"); global.window=global; global.location=new URL("https://panel.test/dsh/demo/"); global.document={baseURI:location.href,addEventListener(){}}; global.addEventListener=()=>{}; global.setInterval=()=>{}; let last; global.fetch=async (input,init)=>{last=[input,init]; return new Request(input,init)};` + runtimeSessionJS + `(async()=>{ const path='/api/dsh-free-search-settings/mutate'; for (const input of [path,new URL(path,location)]) { const result=await fetch(input,{method:'POST',body:'string-body',headers:{'X-Test':'yes'},credentials:'include'}); assert.equal(result.url,location.origin+'/dsh/demo'+path); assert.equal(await result.text(),'string-body'); assert.equal(result.headers.get('X-Test'),'yes'); assert.equal(result.credentials,'include'); } const abort=new AbortController(); const input=new Request(new URL(path,location),{method:'POST',body:'request-body',headers:{'X-Test':'request'},signal:abort.signal,credentials:'omit',redirect:'manual',cache:'no-store'}); const result=await fetch(input); assert.equal(await result.text(),'request-body'); assert.equal(result.headers.get('X-Test'),'request'); assert.equal(result.credentials,'omit'); assert.equal(result.redirect,'manual'); assert.equal(result.cache,'no-store'); abort.abort(); assert.equal(result.signal.aborted,true); const override=await fetch(new Request(new URL(path,location),{method:'POST',body:'old'}),{body:'new',headers:{'X-Test':'override'}}); assert.equal(await override.text(),'new'); assert.equal(override.headers.get('X-Test'),'override'); const stream = new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('stream-body'));c.close()}}); const streamed=await fetch(new Request(new URL(path,location),{method:'POST',body:stream,duplex:'half'})); assert.equal(await streamed.text(),'stream-body'); const used=new Request(new URL(path,location),{method:'POST',body:'used'}); await used.text(); await assert.rejects(fetch(used),TypeError); for(const path of ['/api/refresh','/api/dsh-free-search-settings/mutate/','/api/dsh-free-search-settings/mutate-extra','/dsh/demo/api/dsh-free-search-settings/mutate','https://evil.test/api/dsh-free-search-settings/mutate']) { const input=new URL(path,location); const init={method:'POST',body:'unchanged'}; await fetch(input,init); assert.equal(last[0],input); assert.equal(last[1],init); } })().catch(e=>{console.error(e);process.exit(1)});` if output, err := exec.Command(node, "-e", script).CombinedOutput(); err != nil { t.Fatalf("%v: %s", err, output) } }