Skip to content

Commit e054922

Browse files
author
root
committed
fix web test
1 parent a32bf3d commit e054922

14 files changed

Lines changed: 439 additions & 31 deletions

File tree

backend/domain/routes/deploy.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,17 @@ func registerOperationRoutes(g *router.RouterGroup[*core.RequestEvent]) {
2929
o.DELETE("/{id}", handleOperationDelete)
3030
o.POST("/{id}/cancel", handleOperationCancel)
3131
o.GET("/{id}/logs", handleOperationLogs)
32-
o.GET("/{id}/stream", handleOperationLogStream)
3332
o.POST("/install/name-availability", handleOperationInstallNameAvailability)
3433
o.POST("/install/git-compose", handleOperationInstallGitCompose)
3534
o.POST("/install/manual-compose", handleOperationInstallManualCompose)
3635
o.POST("/install/git-compose/check", handleOperationInstallGitComposeCheck)
3736
o.POST("/install/manual-compose/check", handleOperationInstallManualComposeCheck)
3837

38+
stream := g.Group("/actions")
39+
stream.Bind(wsTokenAuth())
40+
stream.Bind(apis.RequireSuperuserAuth())
41+
stream.GET("/{id}/stream", handleOperationLogStream)
42+
3943
p := g.Group("/pipelines")
4044
p.Bind(apis.RequireSuperuserAuth())
4145
p.GET("", handlePipelineList)

backend/domain/routes/deploy_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,57 @@ func (te *testEnv) doOperations(t *testing.T, method, url, body string, authenti
4949
return rec
5050
}
5151

52+
func (te *testEnv) doRegisteredRoute(t *testing.T, method, url, body string, headers map[string]string) *httptest.ResponseRecorder {
53+
t.Helper()
54+
55+
r, err := apis.NewRouter(te.app)
56+
if err != nil {
57+
t.Fatal(err)
58+
}
59+
60+
Register(&core.ServeEvent{App: te.app, Router: r})
61+
62+
mux, err := r.BuildMux()
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
67+
var bodyReader io.Reader
68+
if body != "" {
69+
bodyReader = strings.NewReader(body)
70+
}
71+
72+
req := httptest.NewRequest(method, url, bodyReader)
73+
req.Header.Set("Content-Type", "application/json")
74+
for key, value := range headers {
75+
req.Header.Set(key, value)
76+
}
77+
78+
rec := httptest.NewRecorder()
79+
mux.ServeHTTP(rec, req)
80+
return rec
81+
}
82+
83+
func TestRegisterRejectsQueryTokenForPlainHTTPAPI(t *testing.T) {
84+
te := newTestEnv(t)
85+
defer te.cleanup()
86+
87+
rec := te.doRegisteredRoute(t, http.MethodGet, "/api/catalog/categories?token="+te.token, "", nil)
88+
if rec.Code != http.StatusUnauthorized {
89+
t.Fatalf("expected 401 for plain HTTP query-token auth, got %d: %s", rec.Code, rec.Body.String())
90+
}
91+
}
92+
93+
func TestOperationLogStreamAllowsQueryTokenAuth(t *testing.T) {
94+
te := newTestEnv(t)
95+
defer te.cleanup()
96+
97+
rec := te.doOperations(t, http.MethodGet, "/api/actions/missing-id/stream?token="+te.token, "", false)
98+
if rec.Code != http.StatusNotFound {
99+
t.Fatalf("expected 404 after query-token auth reached stream handler, got %d: %s", rec.Code, rec.Body.String())
100+
}
101+
}
102+
52103
func TestOperationManualComposeCreateListDetail(t *testing.T) {
53104
te := newTestEnv(t)
54105
defer te.cleanup()

backend/domain/routes/docker.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strconv"
88
"sync"
99

10+
"github.com/pocketbase/pocketbase/apis"
1011
"github.com/pocketbase/pocketbase/core"
1112
"github.com/pocketbase/pocketbase/tools/router"
1213
"github.com/websoft9/appos/backend/domain/audit"
@@ -38,6 +39,7 @@ func init() {
3839
// /api/ext/docker/volumes/* — volume management
3940
func registerDockerRoutes(g *router.RouterGroup[*core.RequestEvent]) {
4041
d := g.Group("/docker")
42+
d.Bind(apis.RequireSuperuserAuth())
4143

4244
// ─── Servers list ───────────────────────────────────
4345
d.GET("/servers", handleDockerServers)

backend/domain/routes/docker_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package routes
22

33
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
47
"testing"
58

9+
"github.com/pocketbase/pocketbase/apis"
10+
"github.com/pocketbase/pocketbase/core"
611
servers "github.com/websoft9/appos/backend/domain/resource/servers"
712
)
813

@@ -57,3 +62,69 @@ func TestTunnelSSHPortFromServices(t *testing.T) {
5762
})
5863
}
5964
}
65+
66+
func doDocker(t *testing.T, te *testEnv, method, url, body, token string) *httptest.ResponseRecorder {
67+
t.Helper()
68+
69+
r, err := apis.NewRouter(te.app)
70+
if err != nil {
71+
t.Fatal(err)
72+
}
73+
74+
g := r.Group("/api/ext")
75+
g.Bind(apis.RequireAuth())
76+
registerDockerRoutes(g)
77+
78+
mux, err := r.BuildMux()
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
83+
req := httptest.NewRequest(method, url, strings.NewReader(body))
84+
req.Header.Set("Content-Type", "application/json")
85+
if token != "" {
86+
req.Header.Set("Authorization", token)
87+
}
88+
89+
rec := httptest.NewRecorder()
90+
mux.ServeHTTP(rec, req)
91+
return rec
92+
}
93+
94+
func createRegularUserToken(t *testing.T, te *testEnv) string {
95+
t.Helper()
96+
97+
usersCol, err := te.app.FindCollectionByNameOrId("users")
98+
if err != nil {
99+
t.Fatal(err)
100+
}
101+
user := core.NewRecord(usersCol)
102+
user.Set("email", "user@test.com")
103+
user.SetPassword("1234567890")
104+
if err := te.app.Save(user); err != nil {
105+
t.Fatal(err)
106+
}
107+
108+
token, err := user.NewStaticAuthToken(0)
109+
if err != nil {
110+
t.Fatal(err)
111+
}
112+
return token
113+
}
114+
115+
func TestDockerRoutesRequireSuperuser(t *testing.T) {
116+
te := newTestEnv(t)
117+
defer te.cleanup()
118+
119+
userToken := createRegularUserToken(t, te)
120+
121+
rec := doDocker(t, te, http.MethodGet, "/api/ext/docker/servers", "", userToken)
122+
if rec.Code != http.StatusForbidden {
123+
t.Fatalf("expected 403 for non-superuser, got %d: %s", rec.Code, rec.Body.String())
124+
}
125+
126+
rec = doDocker(t, te, http.MethodGet, "/api/ext/docker/servers", "", te.token)
127+
if rec.Code != http.StatusOK {
128+
t.Fatalf("expected 200 for superuser, got %d: %s", rec.Code, rec.Body.String())
129+
}
130+
}

backend/domain/routes/routes.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ func Register(se *core.ServeEvent) {
7171
components.Bind(apis.RequireAuth())
7272

7373
deployments := se.Router.Group("/api")
74-
deployments.Bind(wsTokenAuth())
7574
deployments.Bind(apis.RequireAuth())
7675

7776
// Server catalog routes (ops, ports, systemd) — no terminal sessions

backend/domain/routes/server.go

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"net/url"
78
"strings"
89

910
"github.com/gorilla/websocket"
@@ -16,8 +17,7 @@ import (
1617
)
1718

1819
var wsUpgrader = websocket.Upgrader{
19-
// TODO: validate Origin header for production CSRF protection.
20-
CheckOrigin: func(r *http.Request) bool { return true },
20+
CheckOrigin: allowWebSocketOrigin,
2121
}
2222

2323
var dockerBridgeIPv4Lookup = netutil.LookupInterfaceIPv4
@@ -69,6 +69,83 @@ func wsTokenAuth() *hook.Handler[*core.RequestEvent] {
6969
}
7070
}
7171

72+
func allowWebSocketOrigin(r *http.Request) bool {
73+
origin := strings.TrimSpace(r.Header.Get("Origin"))
74+
if origin == "" {
75+
return true
76+
}
77+
parsed, err := url.Parse(origin)
78+
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
79+
return false
80+
}
81+
requestScheme := resolveWebSocketHTTPScheme(r)
82+
if !strings.EqualFold(parsed.Scheme, requestScheme) {
83+
return false
84+
}
85+
return sameWebSocketOriginHost(parsed.Host, resolveWebSocketHTTPHost(r), requestScheme)
86+
}
87+
88+
func resolveWebSocketHTTPScheme(r *http.Request) string {
89+
if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") || r.TLS != nil {
90+
return "https"
91+
}
92+
return "http"
93+
}
94+
95+
func resolveWebSocketHTTPHost(r *http.Request) string {
96+
host := firstForwardedHostValue(r.Host)
97+
forwardedHost := firstForwardedHostValue(r.Header.Get("X-Forwarded-Host"))
98+
if host == "" {
99+
host = forwardedHost
100+
}
101+
if forwardedHost != "" && forwardedHostCarriesPort(host, forwardedHost) {
102+
host = forwardedHost
103+
}
104+
if !hostHasExplicitPort(host) {
105+
if forwardedPort := firstForwardedPortValue(r.Header.Get("X-Forwarded-Port")); forwardedPort != "" {
106+
host = appendPortIfMissing(host, forwardedPort)
107+
}
108+
}
109+
return host
110+
}
111+
112+
func sameWebSocketOriginHost(originHost string, requestHost string, scheme string) bool {
113+
if !strings.EqualFold(stripOptionalPort(originHost), stripOptionalPort(requestHost)) {
114+
return false
115+
}
116+
return effectivePort(originHost, scheme) == effectivePort(requestHost, scheme)
117+
}
118+
119+
func effectivePort(host string, scheme string) string {
120+
if host == "" {
121+
return defaultPortForScheme(scheme)
122+
}
123+
if strings.HasPrefix(host, "[") {
124+
if idx := strings.LastIndex(host, "]:"); idx >= 0 {
125+
return host[idx+2:]
126+
}
127+
return defaultPortForScheme(scheme)
128+
}
129+
idx := strings.LastIndex(host, ":")
130+
if idx <= 0 || strings.Contains(host[:idx], ":") {
131+
return defaultPortForScheme(scheme)
132+
}
133+
port := host[idx+1:]
134+
for _, ch := range port {
135+
if ch < '0' || ch > '9' {
136+
return defaultPortForScheme(scheme)
137+
}
138+
}
139+
return port
140+
}
141+
142+
func defaultPortForScheme(scheme string) string {
143+
if strings.EqualFold(strings.TrimSpace(scheme), "https") {
144+
return "443"
145+
}
146+
return "80"
147+
}
148+
72149
// registerServerRoutes registers server catalog/ops routes (non-terminal).
73150
// These handle connectivity checks, power, ports, and systemd operations.
74151
func registerServerRoutes(g *router.RouterGroup[*core.RequestEvent]) {

backend/domain/routes/server_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,40 @@ func TestLocalDockerBridgeRequiresAuth(t *testing.T) {
5252
}
5353
}
5454

55+
func TestAllowWebSocketOriginAllowsEmptyOrigin(t *testing.T) {
56+
req := httptest.NewRequest(http.MethodGet, "https://console.example.com/api/actions/demo/stream", nil)
57+
if !allowWebSocketOrigin(req) {
58+
t.Fatal("expected empty origin to be allowed")
59+
}
60+
}
61+
62+
func TestAllowWebSocketOriginAllowsSameOrigin(t *testing.T) {
63+
req := httptest.NewRequest(http.MethodGet, "https://console.example.com/api/actions/demo/stream", nil)
64+
req.Header.Set("Origin", "https://console.example.com")
65+
if !allowWebSocketOrigin(req) {
66+
t.Fatal("expected same origin to be allowed")
67+
}
68+
}
69+
70+
func TestAllowWebSocketOriginRejectsCrossOrigin(t *testing.T) {
71+
req := httptest.NewRequest(http.MethodGet, "https://console.example.com/api/actions/demo/stream", nil)
72+
req.Header.Set("Origin", "https://evil.example.com")
73+
if allowWebSocketOrigin(req) {
74+
t.Fatal("expected cross origin to be rejected")
75+
}
76+
}
77+
78+
func TestAllowWebSocketOriginUsesForwardedProxyHostAndProto(t *testing.T) {
79+
req := httptest.NewRequest(http.MethodGet, "http://internal.example.local/api/actions/demo/stream", nil)
80+
req.Host = "console.example.com"
81+
req.Header.Set("X-Forwarded-Host", "console.example.com:9443")
82+
req.Header.Set("X-Forwarded-Proto", "https")
83+
req.Header.Set("Origin", "https://console.example.com:9443")
84+
if !allowWebSocketOrigin(req) {
85+
t.Fatal("expected forwarded proxy origin to be allowed")
86+
}
87+
}
88+
5589
func TestLocalDockerBridgeReturnsAddress(t *testing.T) {
5690
te := newTestEnv(t)
5791
defer te.cleanup()

0 commit comments

Comments
 (0)