142 lines
7.0 KiB
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)
|
|
}
|
|
}
|