package centrifugo import ( "bytes" "crypto/subtle" "encoding/json" "log/slog" "net/http" "sort" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lighthouse" "git.golem15.com/golem15/summercms/modules/surf" ) type errorBody struct { Error string `json:"error"` } type tokenBody struct { Token string `json:"token"` } // TokenHandler issues the connection token of the signed-in user. It must // be mounted behind a user guard (the UserAuth surface). // // - no principal, or no user for it: 401 {"error":"Unauthorized"} // - no token secret: 503 with the WinterCMS not-configured error body // - otherwise 200 {"token":"…"} (see TokenIssuer.ForUser) func TokenHandler(svc *lighthouse.Service, issuer *TokenIssuer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p, ok := bouncer.User(r.Context()) if !ok || p == nil || p.ID == 0 { writeJSON(w, http.StatusUnauthorized, errorBody{Error: "Unauthorized"}) return } u, found, err := svc.User(r.Context(), p.ID) if err != nil || !found { writeJSON(w, http.StatusUnauthorized, errorBody{Error: "Unauthorized"}) return } if !issuer.Configured() { writeJSON(w, http.StatusServiceUnavailable, errorBody{Error: "WebSocket not configured"}) return } tok, err := issuer.ForUser(u) if err != nil { svc.Logger().Error("realtime: token signing failed", "error", err) writeJSON(w, http.StatusInternalServerError, errorBody{Error: "Internal server error"}) return } writeJSON(w, http.StatusOK, tokenBody{Token: tok}) } } // writeJSON writes v with no trailing newline and no HTML escaping, plus the // Content-Type and Cache-Control headers of a Laravel JSON response. func writeJSON(w http.ResponseWriter, status int, v any) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { w.WriteHeader(http.StatusInternalServerError) return } h := w.Header() h.Set("Content-Type", "application/json") h.Set("Cache-Control", "no-cache, private") w.WriteHeader(status) _, _ = w.Write(bytes.TrimSuffix(buf.Bytes(), []byte("\n"))) } // maxProxyBody caps a subscribe proxy request body. const maxProxyBody = 64 << 10 // denyAnswer is the one answer every denied subscribe gets. Centrifugo // reads a non-200 status as internal error 100, so a deny is HTTP 200 with // an error body; it never reveals why. var denyAnswer = json.RawMessage(`{"error":{"code":403,"message":"` + accessDenied + `"}}`) // accessDenied is the generic deny message, also logged when an authorizer // gives no reason. const accessDenied = "Access denied" // proxyRequest is the part of Centrifugo's subscribe proxy body the // handler reads. type proxyRequest struct { User json.RawMessage `json:"user"` Channel *string `json:"channel"` Client string `json:"client"` } // ProxyHandler is the Centrifugo subscribe proxy endpoint, the WinterCMS // ProxyController::subscribe. Every subscribe is re-authorized: // // 1. X-Centrifugo-Secret must equal cfg.ProxySecret (constant-time; an // empty configured secret denies everything); // 2. an empty or "0" user denies; // 3. the namespace comes from lighthouse.ParseChannel and must have a // registered authorizer; // 4. the authorizer gets the user id (PHP (int) cast, negative as 0) and // the full original channel, with the client id in ctx. // // An allow answers {"result":{"info":…}} with info [] when empty; a // presence channel also gets "allow" (the authorizer's capabilities or // ["prs"]) and "override" (presence and join_leave true, // force_push_join_leave false, then the authorizer's overrides). Every // deny answers the same HTTP 200 body and logs the reason at Warn. func ProxyHandler(svc *lighthouse.Service, cfg Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { log := svc.Logger() provided := r.Header.Get("X-Centrifugo-Secret") if cfg.ProxySecret == "" || subtle.ConstantTimeCompare([]byte(cfg.ProxySecret), []byte(provided)) != 1 { deny(w, log, "Invalid or missing proxy secret", slog.String("ip", surf.ClientIP(r, cfg.TrustedProxies))) return } var req proxyRequest dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxProxyBody)) dec.UseNumber() if err := dec.Decode(&req); err != nil { deny(w, log, "Malformed proxy request", slog.String("ip", surf.ClientIP(r, cfg.TrustedProxies))) return } user := userInput(req.User) channel := "" if req.Channel != nil { channel = *req.Channel } if user == "" || user == "0" { deny(w, log, "Authentication required", slog.String("channel", channel), slog.String("client", req.Client)) return } if req.Channel == nil || channel == "" { // Centrifugo always sends a channel; WinterCMS would fail with // a TypeError here. Denying is deliberate hardening. deny(w, log, "Missing channel", slog.String("user", user), slog.String("client", req.Client)) return } namespace, presence := lighthouse.ParseChannel(channel) authorizer, ok := svc.Registry().Get(namespace) if !ok { deny(w, log, "Unknown channel namespace", slog.String("namespace", namespace), slog.String("channel", channel), slog.String("user", user), slog.String("client", req.Client)) return } id := lighthouse.PHPInt(user) if id < 0 { id = 0 } ctx := lighthouse.WithClientID(r.Context(), req.Client) res := authorizer.Authorize(ctx, uint(id), channel) if !res.Allowed { reason := res.Reason() if reason == "" { reason = accessDenied } deny(w, log, reason, slog.String("user", user), slog.String("channel", channel), slog.String("client", req.Client), slog.String("internal_reason", res.Reason())) return } allow(w, res, presence) } } // userInput returns the user field as PHP's $request->input('user') string // form: a JSON string as is, a JSON number as its literal. Anything else is // "" (treated as empty). func userInput(raw json.RawMessage) string { raw = bytes.TrimSpace(raw) if len(raw) == 0 { return "" } switch raw[0] { case '"': var s string if err := json.Unmarshal(raw, &s); err != nil { return "" } return s case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': var n json.Number if err := json.Unmarshal(raw, &n); err != nil { return "" } if f, err := n.Float64(); err == nil && f == 0 { return "0" // PHP empty(0) and empty(0.0) } return n.String() default: return "" } } func deny(w http.ResponseWriter, log *slog.Logger, reason string, attrs ...slog.Attr) { args := make([]any, 0, len(attrs)+1) args = append(args, slog.String("reason", reason)) for _, a := range attrs { args = append(args, a) } log.Warn("Subscription denied", args...) writeRaw(w, denyAnswer) } func allow(w http.ResponseWriter, res lighthouse.Result, presence bool) { var buf bytes.Buffer buf.WriteString(`{"result":{"info":`) if len(res.Info) == 0 { buf.WriteString(`[]`) } else if b, err := marshal(res.Info); err == nil { buf.Write(b) } else { writeRaw(w, denyAnswer) return } if presence { caps := res.Capabilities if caps == nil { caps = []string{"prs"} } b, err := marshal(caps) if err != nil { writeRaw(w, denyAnswer) return } buf.WriteString(`,"allow":`) buf.Write(b) o, err := overrideJSON(res.Overrides) if err != nil { writeRaw(w, denyAnswer) return } buf.WriteString(`,"override":`) buf.Write(o) } buf.WriteString(`}}`) writeRaw(w, buf.Bytes()) } // overrideJSON is PHP array_merge of the presence defaults with the // authorizer's overrides: the default keys keep their order and take the // authorizer's value when it names them; extra keys follow, sorted. func overrideJSON(overrides map[string]any) ([]byte, error) { defaults := []struct { key string value any }{ {"presence", map[string]bool{"value": true}}, {"join_leave", map[string]bool{"value": true}}, {"force_push_join_leave", map[string]bool{"value": false}}, } var buf bytes.Buffer buf.WriteByte('{') seen := map[string]bool{} write := func(key string, value any) error { k, err := marshal(key) if err != nil { return err } v, err := marshal(value) if err != nil { return err } if buf.Len() > 1 { buf.WriteByte(',') } buf.Write(k) buf.WriteByte(':') buf.Write(v) return nil } for _, d := range defaults { v := d.value if o, ok := overrides[d.key]; ok { v = o } seen[d.key] = true if err := write(d.key, v); err != nil { return nil, err } } extra := make([]string, 0, len(overrides)) for k := range overrides { if !seen[k] { extra = append(extra, k) } } sort.Strings(extra) for _, k := range extra { if err := write(k, overrides[k]); err != nil { return nil, err } } buf.WriteByte('}') return buf.Bytes(), nil } func writeRaw(w http.ResponseWriter, body []byte) { h := w.Header() h.Set("Content-Type", "application/json") h.Set("Cache-Control", "no-cache, private") w.WriteHeader(http.StatusOK) _, _ = w.Write(bytes.TrimSuffix(body, []byte("\n"))) }