Files
dsh-web/internal/api/runtime_free_search_test.go

142 lines
7.0 KiB
Go

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)
}
}