diff --git a/README.md b/README.md index 6393a11..609e74d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Who publishes: - **casaos** POSTs `casaos:system:utilization` every 5 seconds over loopback — CPU, memory, network, disks — plus `casaos:file:operate` while a file operation runs. - **local-storage** publishes `local-storage:disk:added` and its siblings, built from kernel uevents as drives appear and disappear. -- **app-management** publishes `app:*` events — install progress, start, stop, errors — over the unix socket at `/tmp/message-bus.sock` rather than the TCP port. +- **app-management** publishes `app:*` events — install progress, start, stop, errors — over the unix socket at `/var/run/casaos/message-bus.sock` rather than the TCP port. Who subscribes: the dashboard, over socket.io at `/v2/message_bus/socket.io/`. Its CPU, network and disk widgets redraw on each `casaos:system:utilization`; app cards follow `app:apply-changes-begin`, `app:start-end` and the rest. A plain websocket subscription to one source is also available at `GET /v2/message_bus/event/{source_id}`. @@ -22,7 +22,7 @@ Delivery is fire and forget. An event nobody is subscribed to is dropped, not qu The service also owns the YSK cards on the dashboard — task and notice cards — served at `/v2/message_bus/ysk`. That is the only state it keeps of its own. -Requests carry a CasaOS JWT. Loopback alone is not trusted. Three kinds of request skip the token: those on the unix socket, which only root can open; those from loopback with `Authorization: Internal `, the per-boot secret the gateway writes to `/var/run/casaos/internal.secret` (root only); and websocket upgrades on the four subscribe routes — `GET /event/{source_id}`, `GET /action/{source_id}`, `GET /socket.io` and `GET /socket.io/` — because the dashboard subscribes without a token. Whoever reaches those routes can read the event stream; every other route, socket.io polling included, needs the token. +Every request carries a CasaOS JWT in `Authorization`, subscriptions included. Loopback alone is not trusted. Two kinds of request skip the token: those on the unix socket, which only root can open, and those from loopback with `Authorization: Internal `, the per-boot secret the gateway writes to `/var/run/casaos/internal.secret` (root only) — how user-service subscribes. Browsers cannot set headers on a WebSocket, so the subscription routes — `GET /event/{source_id}`, `GET /action/{source_id}` and `GET`/`POST` on `/socket.io` and `/socket.io/`, websocket and polling alike — also take the JWT from the `token` query parameter, as the dashboard sends it. Every other route ignores that parameter. The access log records the request path without its query string, so the token never reaches the log. ## Where things live @@ -32,7 +32,7 @@ Requests carry a CasaOS JWT. Loopback alone is not trusted. Three kinds of reque | `/var/run/casaos/message-bus.db` | registered event and action types; runtime only, and services re-register on start | | `/var/lib/casaos/db/message-bus.db` | YSK cards and settings, kept across reboots | | `/var/run/casaos/message-bus.url` | the TCP address the gateway and other services read | -| `/tmp/message-bus.sock` | the unix socket, same API | +| `/var/run/casaos/message-bus.sock` | the unix socket, same API; root only | | `/var/log/casaos/message-bus.log` | log | The service picks a free loopback port at start, registers `/v2/message_bus` and `/doc/v2/message_bus` with the gateway, and writes its address to the file above. The API specification is [`api/message_bus/openapi.yaml`](api/message_bus/openapi.yaml), also served at `/doc/v2/message_bus`. @@ -51,6 +51,8 @@ What a release contains, and how it is built, is described in [CasaOS-Install](h **The access log no longer records host-side event publishes.** casaos's 5-second telemetry produced one JSON access-log line per publish, which systemd hands to journald: about 17 000 lines a day that buried every real request. The telemetry rate is unchanged — it is the dashboard's only realtime feed — and the log line is skipped instead. The skip is narrow: POST on the event publish route, and only from the unix socket or a loopback peer. Websocket subscribes, event-type registrations, actions and anything from the LAN stay logged ([CasaOS #2211](https://github.com/IceWhaleTech/CasaOS/issues/2211)). +**Subscriptions need a credential, and the unix socket left `/tmp`.** Upstream let any websocket upgrade on the subscribe routes through without a token, so whoever reached the dashboard's port could read every event; a subscription now needs a user JWT (header or `?token=`) or the internal secret. The socket moved out of `/tmp`, where any local user could take its name first and keep the bus from listening, to the runtime directory, which only root can write. + **The Go module is now `github.com/ReCasaOS/CasaOS-MessageBus`, built against `github.com/ReCasaOS/CasaOS-Common`,** so log lines, stack traces and `go version -m` on the shipped binary name the fork that ships them rather than upstream. Packaging changed too, without touching the service: the release workflow builds and publishes the tarballs here instead of calling upstream's pipeline, which needed credentials this fork does not have and so never produced a release. diff --git a/api/message_bus/openapi.yaml b/api/message_bus/openapi.yaml index b4d2cda..e72b607 100644 --- a/api/message_bus/openapi.yaml +++ b/api/message_bus/openapi.yaml @@ -177,6 +177,9 @@ paths: > `names` can be specified in the query string to subscribe to specific event types. operationId: subscribeEventWS + security: + - access_token: [] + - access_token_query: [] tags: - WebSocket Endpoints parameters: @@ -278,6 +281,9 @@ paths: > `names` can be specified in the query string to subscribe to specific action types. operationId: subscribeActionWS + security: + - access_token: [] + - access_token_query: [] tags: - WebSocket Endpoints parameters: @@ -297,6 +303,9 @@ paths: - SocketIO `room` should be either `event` or `action` operationId: subscribeSIO + security: + - access_token: [] + - access_token_query: [] tags: - SocketIO Endpoints responses: @@ -312,6 +321,9 @@ paths: description: | Poll events and actions by via SocketIO. operationId: pollSIO + security: + - access_token: [] + - access_token_query: [] tags: - SocketIO Endpoints responses: @@ -325,6 +337,9 @@ paths: description: | > Same as `/socket.io` except it comes with a trailing slash `/` - a workaround to maximize compatibility operationId: subscribeSIO2 + security: + - access_token: [] + - access_token_query: [] responses: "101": description: | @@ -338,6 +353,9 @@ paths: description: | > Same as `/socket.io` except it comes with a trailing slash `/` - a workaround to maximize compatibility operationId: pollSIO2 + security: + - access_token: [] + - access_token_query: [] responses: "200": description: | @@ -380,6 +398,14 @@ components: type: apiKey in: header name: Authorization + description: | + A CasaOS user JWT. This box's own services send `Internal ` instead, from loopback, with the per-boot secret the gateway writes to `/var/run/casaos/internal.secret`; requests on the unix socket need neither. + access_token_query: + type: apiKey + in: query + name: token + description: | + The same user JWT, in the query string, because browsers cannot set headers on a WebSocket. Read on the subscription routes only (the WebSocket and socket.io endpoints) and ignored everywhere else. parameters: SourceID: diff --git a/codegen/message_bus_api.go b/codegen/message_bus_api.go index a5abb83..aa01568 100644 --- a/codegen/message_bus_api.go +++ b/codegen/message_bus_api.go @@ -20,7 +20,8 @@ import ( ) const ( - Access_tokenScopes = "access_token.Scopes" + Access_tokenScopes = "access_token.Scopes" + Access_token_queryScopes = "access_token_query.Scopes" ) // Defines values for YSKCardCardType. @@ -357,6 +358,8 @@ func (w *ServerInterfaceWrapper) SubscribeActionWS(ctx echo.Context) error { ctx.Set(Access_tokenScopes, []string{""}) + ctx.Set(Access_token_queryScopes, []string{""}) + // Parameter object where we will unmarshal all parameters from the context var params SubscribeActionWSParams // ------------- Optional query parameter "names" ------------- @@ -476,6 +479,8 @@ func (w *ServerInterfaceWrapper) SubscribeEventWS(ctx echo.Context) error { ctx.Set(Access_tokenScopes, []string{""}) + ctx.Set(Access_token_queryScopes, []string{""}) + // Parameter object where we will unmarshal all parameters from the context var params SubscribeEventWSParams // ------------- Optional query parameter "names" ------------- @@ -588,6 +593,8 @@ func (w *ServerInterfaceWrapper) SubscribeSIO(ctx echo.Context) error { ctx.Set(Access_tokenScopes, []string{""}) + ctx.Set(Access_token_queryScopes, []string{""}) + // Invoke the callback with all the unmarshalled arguments err = w.Handler.SubscribeSIO(ctx) return err @@ -599,6 +606,8 @@ func (w *ServerInterfaceWrapper) PollSIO(ctx echo.Context) error { ctx.Set(Access_tokenScopes, []string{""}) + ctx.Set(Access_token_queryScopes, []string{""}) + // Invoke the callback with all the unmarshalled arguments err = w.Handler.PollSIO(ctx) return err @@ -610,6 +619,8 @@ func (w *ServerInterfaceWrapper) SubscribeSIO2(ctx echo.Context) error { ctx.Set(Access_tokenScopes, []string{""}) + ctx.Set(Access_token_queryScopes, []string{""}) + // Invoke the callback with all the unmarshalled arguments err = w.Handler.SubscribeSIO2(ctx) return err @@ -621,6 +632,8 @@ func (w *ServerInterfaceWrapper) PollSIO2(ctx echo.Context) error { ctx.Set(Access_tokenScopes, []string{""}) + ctx.Set(Access_token_queryScopes, []string{""}) + // Invoke the callback with all the unmarshalled arguments err = w.Handler.PollSIO2(ctx) return err @@ -707,60 +720,65 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "+EsAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/go.mod b/go.mod index c115c82..845e1a9 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.8 require ( github.com/CorrectRoadH/go-socket.io v1.8.0-rc.5 - github.com/ReCasaOS/CasaOS-Common v0.4.26 + github.com/ReCasaOS/CasaOS-Common v0.4.27 github.com/glebarez/sqlite v1.7.0 github.com/gobwas/ws v1.1.0 github.com/json-iterator/go v1.1.12 diff --git a/go.sum b/go.sum index b4e5a01..6030451 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/CorrectRoadH/go-socket.io v1.8.0-rc.5 h1:cjlXtmuOug5168+XRM+7iJxbd9lLkL9AgzemF7ZqoPI= github.com/CorrectRoadH/go-socket.io v1.8.0-rc.5/go.mod h1:DgupkgFQg7/js8Tr1uLYICJRGlHmKWIV6hWWTH2V2fc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/ReCasaOS/CasaOS-Common v0.4.26 h1:aefnl58xQ4z0ga0+vBnQHmkG1/3fnd1C1W/EBFeAgvc= -github.com/ReCasaOS/CasaOS-Common v0.4.26/go.mod h1:HjYCcRAVDa0c/IncJDof1sddCpze2esCTZt941Sk7LI= +github.com/ReCasaOS/CasaOS-Common v0.4.27 h1:7V3rAZj77wII/tqJeu9eqxEvAYln0kpaeK1GTuzTXSk= +github.com/ReCasaOS/CasaOS-Common v0.4.27/go.mod h1:HjYCcRAVDa0c/IncJDof1sddCpze2esCTZt941Sk7LI= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= diff --git a/main.go b/main.go index 9b048cc..6bffeca 100644 --- a/main.go +++ b/main.go @@ -5,8 +5,10 @@ package main import ( "context" _ "embed" + "errors" "flag" "fmt" + "io/fs" "net" "net/http" "net/url" @@ -17,7 +19,6 @@ import ( "github.com/ReCasaOS/CasaOS-Common/external" "github.com/ReCasaOS/CasaOS-Common/model" - "github.com/ReCasaOS/CasaOS-Common/utils/file" util_http "github.com/ReCasaOS/CasaOS-Common/utils/http" "github.com/ReCasaOS/CasaOS-Common/utils/logger" "github.com/ReCasaOS/CasaOS-MessageBus/codegen" @@ -44,8 +45,6 @@ var ( //go:embed build/sysroot/etc/casaos/message-bus.conf.sample _confSample string - - unixSocketPath = "/tmp/message-bus.sock" ) func main() { @@ -69,9 +68,16 @@ func main() { logger.LogInit(config.AppInfo.LogPath, config.AppInfo.LogSaveName, config.AppInfo.LogFileExt) // repository - if err := file.IsNotExistMkDir(config.CommonInfo.RuntimePath); err != nil { + if err := os.MkdirAll(config.CommonInfo.RuntimePath, 0o755); err != nil { panic(err) } + // MkdirAll leaves an existing directory as it is, and buses before this one + // created it 0777: anyone could then swap the socket that lives in it. + if info, err := os.Stat(config.CommonInfo.RuntimePath); err == nil && info.Mode().Perm()&0o022 != 0 { + if err := os.Chmod(config.CommonInfo.RuntimePath, 0o755); err != nil { + panic(err) + } + } databaseFilePath := filepath.Join(config.CommonInfo.RuntimePath, "message-bus.db") persistDatabaseFilePath := filepath.Join(config.AppInfo.DBPath, "db", "message-bus.db") @@ -119,16 +125,19 @@ func main() { panic(err) } - // remove unix socket file. don't need check whether it exists or not - os.Remove(unixSocketPath) - // socket listener + // socket listener, in the runtime path (created above), which only root can + // write: in /tmp any local user could take the name first and keep the bus + // from listening. A socket left by the previous run would fail the listen. + unixSocketPath := external.MessageBusSocketPath(config.CommonInfo.RuntimePath) + if err := os.Remove(unixSocketPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + panic(err) + } socketListener, err := net.Listen("unix", unixSocketPath) if err != nil { panic(err) } // Requests on this socket skip authentication (route.fromUnixSocket), so only - // root may connect, whatever the umask. /tmp's sticky bit keeps others from - // replacing the file. + // root may connect, whatever the umask. if err := os.Chmod(unixSocketPath, 0o600); err != nil { panic(err) } diff --git a/route/routers.go b/route/routers.go index 4d847a2..25c1257 100644 --- a/route/routers.go +++ b/route/routers.go @@ -47,33 +47,42 @@ func fromUnixSocket(r *http.Request) bool { return ok && addr.Network() == "unix" } -// websocketSubscribeRoutes are the GET routes (as registered by codegen) a -// client subscribes on with a WebSocket upgrade: subscribeEventWS, -// subscribeActionWS, subscribeSIO and subscribeSIO2. Socket.IO polling is not -// among them: the dashboard opens the websocket transport directly. -var websocketSubscribeRoutes = map[string]bool{ - "/v2/message_bus/event/:source_id": true, - "/v2/message_bus/action/:source_id": true, - "/v2/message_bus/socket.io": true, - "/v2/message_bus/socket.io/": true, +// subscriptionRoutes are the routes, method and path as codegen registers them, +// a client subscribes on: subscribeEventWS, subscribeActionWS and the socket.io +// endpoints, websocket and polling alike. Browsers cannot set headers on a +// WebSocket, so these routes, and no other, also take the user JWT from the +// `token` query parameter. +var subscriptionRoutes = map[string]bool{ + "GET /v2/message_bus/event/:source_id": true, + "GET /v2/message_bus/action/:source_id": true, + "GET /v2/message_bus/socket.io": true, + "POST /v2/message_bus/socket.io": true, + "GET /v2/message_bus/socket.io/": true, + "POST /v2/message_bus/socket.io/": true, } // skipJWT reports whether a request needs no user token: it comes from one of -// this box's services (over the unix socket, or from loopback with the internal -// secret the gateway writes each boot), or it is a WebSocket subscribe. +// this box's services, over the unix socket or from loopback with the internal +// secret the gateway writes each boot. func skipJWT(c echo.Context) bool { r := c.Request() - if fromUnixSocket(r) || external.IsInternalRequest(c.RealIP(), r.Header.Get(echo.HeaderAuthorization), config.CommonInfo.RuntimePath) { - return true - } + return fromUnixSocket(r) || external.IsInternalRequest(c.RealIP(), r.Header.Get(echo.HeaderAuthorization), config.CommonInfo.RuntimePath) +} - // Browsers cannot set headers on a WebSocket and the dashboard subscribes - // without a token, so the event stream stays open to anyone who reaches it. - // c.Path() is the route the router matched (it runs before middleware), so - // the Upgrade header opens those routes only, not every GET. - return r.Method == echo.GET && strings.EqualFold(r.Header.Get(echo.HeaderUpgrade), "websocket") && websocketSubscribeRoutes[c.Path()] +// queryToken extracts the JWT from the `token` query parameter on the +// subscription routes only. c.Path() is the route the router matched: it runs +// before middleware. +func queryToken(c echo.Context) ([]string, error) { + if !subscriptionRoutes[c.Request().Method+" "+c.Path()] { + return nil, nil + } + return []string{c.QueryParam("token")}, nil } +// accessLogFormat is echo's default access log with the path in place of the +// request URI: the query string carries the subscription token. +var accessLogFormat = strings.Replace(echo_middleware.DefaultLoggerConfig.Format, `"uri":"${uri}"`, `"path":"${path}"`, 1) + func NewAPIRouter(swagger *openapi3.T, services *service.Services) (http.Handler, error) { apiRoute := NewAPIRoute(services) @@ -91,6 +100,7 @@ func NewAPIRouter(swagger *openapi3.T, services *service.Services) (http.Handler e.Use(echo_middleware.Gzip()) e.Use(echo_middleware.Recover()) e.Use(echo_middleware.LoggerWithConfig(echo_middleware.LoggerConfig{ + Format: accessLogFormat, Skipper: func(c echo.Context) bool { r := c.Request() return skipAccessLog(r.Method, r.URL.Path, c.RealIP(), r.Host) @@ -113,6 +123,7 @@ func NewAPIRouter(swagger *openapi3.T, services *service.Services) (http.Handler func(c echo.Context) ([]string, error) { return []string{c.Request().Header.Get(echo.HeaderAuthorization)}, nil }, + queryToken, }, })) diff --git a/route/routers_test.go b/route/routers_test.go index c3c32d7..52fbf58 100644 --- a/route/routers_test.go +++ b/route/routers_test.go @@ -1,6 +1,7 @@ package route import ( + "bytes" "context" "io" "net" @@ -18,6 +19,7 @@ import ( "github.com/ReCasaOS/CasaOS-MessageBus/repository" "github.com/ReCasaOS/CasaOS-MessageBus/service" "github.com/labstack/echo/v4" + echo_middleware "github.com/labstack/echo/v4/middleware" "gotest.tools/assert" ) @@ -45,6 +47,12 @@ func TestAPIRouterAuth(t *testing.T) { assert.NilError(t, err) defer repository.Close() + // The access logger takes its output from the default config when the + // router is built. + var accessLog bytes.Buffer + defer func(old io.Writer) { echo_middleware.DefaultLoggerConfig.Output = old }(echo_middleware.DefaultLoggerConfig.Output) + echo_middleware.DefaultLoggerConfig.Output = &accessLog + services := service.NewServices(&repository) swagger, err := codegen.GetSwagger() assert.NilError(t, err) @@ -55,15 +63,51 @@ func TestAPIRouterAuth(t *testing.T) { eventTypes = "/v2/message_bus/event_type" body = `[{"sourceID":"foo","name":"bar","propertyTypeList":[]}]` lan = "192.168.1.20:40000" + loopback = "127.0.0.1:40000" + polling = "?EIO=4&transport=polling&sid=nope" ) upgrade := map[string]string{echo.HeaderUpgrade: "websocket", echo.HeaderConnection: "Upgrade"} + withToken := func(target, token string) string { + if strings.Contains(target, "?") { + return target + "&token=" + token + } + return target + "?token=" + token + } - tests := []struct { + type testCase struct { name, method, target, remoteAddr, authorization string header map[string]string unixSocket bool expected int + } + + // The subscription routes. 400 comes from the handler (no such type) or + // from engine.io (no transport, unknown sid): past the JWT middleware. + var subscriptions []testCase + for _, s := range []struct { + name, method, target string + header map[string]string }{ + {"event websocket", http.MethodGet, "/v2/message_bus/event/nobody", upgrade}, + {"action websocket", http.MethodGet, "/v2/message_bus/action/nobody?names=x", upgrade}, + {"socket.io websocket", http.MethodGet, "/v2/message_bus/socket.io", upgrade}, + {"socket.io/ websocket", http.MethodGet, "/v2/message_bus/socket.io/", upgrade}, + {"socket.io polling GET", http.MethodGet, "/v2/message_bus/socket.io" + polling, nil}, + {"socket.io/ polling GET", http.MethodGet, "/v2/message_bus/socket.io/" + polling, nil}, + {"socket.io polling POST", http.MethodPost, "/v2/message_bus/socket.io" + polling, nil}, + {"socket.io/ polling POST", http.MethodPost, "/v2/message_bus/socket.io/" + polling, nil}, + } { + subscriptions = append(subscriptions, + testCase{name: s.name + " without a credential needs a JWT", method: s.method, target: s.target, remoteAddr: lan, header: s.header, expected: http.StatusUnauthorized}, + testCase{name: s.name + " with ?token passes", method: s.method, target: withToken(s.target, token), remoteAddr: lan, header: s.header, expected: http.StatusBadRequest}, + testCase{name: s.name + " with a tampered ?token needs a valid one", method: s.method, target: withToken(s.target, token+"x"), remoteAddr: lan, header: s.header, expected: http.StatusUnauthorized}, + testCase{name: s.name + " with a user JWT header passes", method: s.method, target: s.target, remoteAddr: lan, authorization: token, header: s.header, expected: http.StatusBadRequest}, + testCase{name: s.name + " with the secret from loopback passes", method: s.method, target: s.target, remoteAddr: loopback, authorization: "Internal s3cret", header: s.header, expected: http.StatusBadRequest}, + testCase{name: s.name + " with the secret from the LAN needs a JWT", method: s.method, target: s.target, remoteAddr: lan, authorization: "Internal s3cret", header: s.header, expected: http.StatusUnauthorized}, + ) + } + + tests := append(subscriptions, []testCase{ {name: "loopback without the secret needs a JWT", method: http.MethodGet, target: eventTypes, remoteAddr: "127.0.0.1:40000", expected: http.StatusUnauthorized}, {name: "loopback with a wrong secret needs a JWT", method: http.MethodGet, target: eventTypes, remoteAddr: "127.0.0.1:40000", authorization: "Internal nope", expected: http.StatusUnauthorized}, {name: "loopback with the secret passes", method: http.MethodPost, target: eventTypes, remoteAddr: "127.0.0.1:40000", authorization: "Internal s3cret", expected: http.StatusOK}, @@ -74,19 +118,16 @@ func TestAPIRouterAuth(t *testing.T) { {name: "the unix socket passes", method: http.MethodGet, target: "http://unix" + eventTypes, remoteAddr: "@", unixSocket: true, expected: http.StatusOK}, {name: "LAN with a user JWT passes", method: http.MethodGet, target: eventTypes, remoteAddr: lan, authorization: token, expected: http.StatusOK}, {name: "LAN with a tampered JWT needs a valid one", method: http.MethodGet, target: eventTypes, remoteAddr: lan, authorization: token + "x", expected: http.StatusUnauthorized}, - // The subscribe routes: 400 from the handler (no such type) or from - // engine.io (no transport), past the JWT middleware. - {name: "websocket subscribe to events passes", method: http.MethodGet, target: "/v2/message_bus/event/nobody", remoteAddr: lan, header: upgrade, expected: http.StatusBadRequest}, - {name: "websocket subscribe to actions passes", method: http.MethodGet, target: "/v2/message_bus/action/nobody?names=x", remoteAddr: lan, header: upgrade, expected: http.StatusBadRequest}, - {name: "socket.io websocket passes", method: http.MethodGet, target: "/v2/message_bus/socket.io", remoteAddr: lan, header: upgrade, expected: http.StatusBadRequest}, - {name: "socket.io/ websocket passes", method: http.MethodGet, target: "/v2/message_bus/socket.io/", remoteAddr: lan, header: upgrade, expected: http.StatusBadRequest}, - // The Upgrade header opens the subscribe routes only. + // ?token is read on the subscription routes only. + {name: "?token on event_type needs a JWT", method: http.MethodGet, target: withToken(eventTypes, token), remoteAddr: lan, expected: http.StatusUnauthorized}, + {name: "?token on an event type registration needs a JWT", method: http.MethodPost, target: withToken(eventTypes, token), remoteAddr: lan, expected: http.StatusUnauthorized}, + {name: "?token on an event publish needs a JWT", method: http.MethodPost, target: withToken("/v2/message_bus/event/nobody/x", token), remoteAddr: lan, expected: http.StatusUnauthorized}, + {name: "?token on ysk needs a JWT", method: http.MethodGet, target: withToken("/v2/message_bus/ysk", token), remoteAddr: lan, header: upgrade, expected: http.StatusUnauthorized}, + {name: "?token with POST on the event subscribe path needs a JWT", method: http.MethodPost, target: withToken("/v2/message_bus/event/nobody", token), remoteAddr: lan, expected: http.StatusUnauthorized}, + // A websocket upgrade opens nothing by itself. {name: "websocket upgrade on event_type needs a JWT", method: http.MethodGet, target: eventTypes, remoteAddr: lan, header: upgrade, expected: http.StatusUnauthorized}, {name: "websocket upgrade on action_type needs a JWT", method: http.MethodGet, target: "/v2/message_bus/action_type", remoteAddr: lan, header: upgrade, expected: http.StatusUnauthorized}, - {name: "websocket upgrade on ysk needs a JWT", method: http.MethodGet, target: "/v2/message_bus/ysk", remoteAddr: lan, header: upgrade, expected: http.StatusUnauthorized}, - {name: "subscribe route without upgrade needs a JWT", method: http.MethodGet, target: "/v2/message_bus/event/nobody", remoteAddr: lan, expected: http.StatusUnauthorized}, - {name: "socket.io polling needs a JWT", method: http.MethodPost, target: "/v2/message_bus/socket.io/", remoteAddr: lan, header: upgrade, expected: http.StatusUnauthorized}, - } + }...) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -104,7 +145,7 @@ func TestAPIRouterAuth(t *testing.T) { req.Header.Set(k, v) } if tt.unixSocket { - req = req.WithContext(context.WithValue(req.Context(), http.LocalAddrContextKey, &net.UnixAddr{Name: "/tmp/message-bus.sock", Net: "unix"})) + req = req.WithContext(context.WithValue(req.Context(), http.LocalAddrContextKey, &net.UnixAddr{Name: external.MessageBusSocketPath(runtimePath), Net: "unix"})) } rec := httptest.NewRecorder() @@ -112,6 +153,10 @@ func TestAPIRouterAuth(t *testing.T) { assert.Equal(t, rec.Code, tt.expected, rec.Body.String()) }) } + + // The access log keeps the path of the ?token requests, not their query. + assert.Assert(t, strings.Contains(accessLog.String(), `"path":"/v2/message_bus/event/nobody"`), accessLog.String()) + assert.Assert(t, !strings.Contains(accessLog.String(), token), "the access log holds a token") } func TestSkipAccessLog(t *testing.T) {