package main import ( "context" "crypto/subtle" "net/http" "net/url" "strings" "time" ) // Panel session authorisation and CSRF. // // The panel authenticates with a cookie, which is what makes it a CSRF target: // a request forged by any other origin arrives with the operator's session // attached. Two independent checks close that. // // 1. Double-submit token. At login the server mints a random token, publishes it // in a readable cookie (telesrv_admin_csrf) and requires the same value in the // X-CSRF-Token header of every mutating request. A cross-origin page can make // the browser *send* the cookie but cannot read it, so it cannot produce the // header. Double-submit is the right shape here specifically because this // process keeps no server-side session store: the session lives entirely in a // signed cookie, so there is nowhere to park a per-session token, and the // stateless variant is the one that survives a restart and a second replica. // The token is additionally bound into the signed session claims, so a // cookie-writing neighbour (a sibling subdomain) cannot supply a matching // cookie/header pair of its own choosing either. // // 2. Origin agreement. When the browser states an Origin, it must be this host. // That catches a forged request from a page that somehow does hold a token. // // Both comparisons are constant time, for the same reason the session MAC is. // Panel permission names. They match the strings an operator configures in // TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces. const ( permissionAll = "*" permissionPremiumManage = "premium.manage" permissionBotTokenRead = "bots.token.read" permissionVerificationReview = "verification.review" permissionVerificationRevoke = "verification.revoke" // Third-party bot verification. Deliberately not implied by the official // verification rights above: the two are separate mechanisms over separate // tables, so a session trusted with one queue is not thereby trusted with the // other. review reads and decides applications; manage appoints verifiers, // curates the icon catalogue and strips granted marks. permissionBotVerificationReview = "botverification.review" permissionBotVerificationManage = "botverification.manage" // permissionServerManage gates the whole Server Settings panel: identity // (name/description/icon), .env editing, and Restart/Update -- all of it // meaningfully more sensitive than any domain-data action above (.env // editing exposes every secret the deployment holds; Restart/Update runs // git/go and bounces the live MTProto process), so it is one right, not // split into review/manage like the sections above. permissionServerManage = "server.manage" // permissionAdminsManage gates the operator accounts themselves: creating // them, editing their rights, disabling them, resetting their passwords. // // It is the one right that can grant every other right, so it is never // implied by anything else and is worth handing out to far fewer people // than server.manage. guardManagerRemoval additionally refuses the edit // that would leave nobody holding it. permissionAdminsManage = "admins.manage" // Section rights, in read/manage pairs that follow the sidebar. Reading a // section and changing it are separate grants because most of the people // who need to look at this data never need to alter it. permissionAccountsRead = "accounts.read" permissionAccountsManage = "accounts.manage" permissionChannelsRead = "channels.read" permissionChannelsManage = "channels.manage" permissionBotsRead = "bots.read" permissionBotsManage = "bots.manage" permissionMessagesRead = "messages.read" permissionMessagesManage = "messages.manage" permissionModerationReview = "moderation.review" permissionBroadcastsRead = "broadcasts.read" permissionBroadcastsSend = "broadcasts.send" permissionStorageRead = "storage.read" permissionStorageManage = "storage.manage" // Sticker packs, emoji packs and the GIF catalogue: one section as far as // the panel is concerned, so one pair of rights. permissionContentRead = "content.read" permissionContentManage = "content.manage" permissionUsernamesRead = "usernames.read" permissionUsernamesManage = "usernames.manage" permissionDashboardRead = "dashboard.read" // permissionSessionOnly marks the handful of routes that need a session but // no right: reading who you are, and signing out. It is not a grantable // name -- scopedRoute treats it as "authenticated is enough" -- so it can // never be typed into an account's permission list by mistake. permissionSessionOnly = "" ) // assignablePermissions is the vocabulary the operator-accounts screen offers. // // The wildcard is deliberately absent: it is meaningful in // TELESRV_ADMIN_UI_PERMISSIONS for the break-glass login, but handing "*" to a // named account through a UI is how least privilege quietly stops being a // thing. An operator who genuinely needs everything gets every entry ticked, // which at least leaves a legible record of what was granted. func assignablePermissions() []string { return []string{ permissionAccountsRead, permissionAccountsManage, permissionChannelsRead, permissionChannelsManage, permissionBotsRead, permissionBotsManage, permissionMessagesRead, permissionMessagesManage, permissionModerationReview, permissionBroadcastsRead, permissionBroadcastsSend, permissionContentRead, permissionContentManage, permissionUsernamesRead, permissionUsernamesManage, permissionStorageRead, permissionStorageManage, permissionDashboardRead, permissionPremiumManage, permissionBotTokenRead, permissionVerificationReview, permissionVerificationRevoke, permissionBotVerificationReview, permissionBotVerificationManage, permissionServerManage, permissionAdminsManage, } } // scopedRoute is the only way an API route should be registered. Requiring the // permission as an argument is what makes the panel deny-by-default: a route // cannot be added without someone stating which right it belongs to, so the // failure mode of forgetting is a compile error rather than an endpoint that // quietly answers to everyone. // // permissionSessionOnly is the deliberate exception, spelled out at each use. func (s *server) scopedRoute(permission string, handler http.Handler) http.Handler { if permission == permissionSessionOnly { return s.requireAuthAPI(handler) } return s.requireAuthAPI(s.requirePermission(permission, handler)) } // scopedRouteAll is scopedRoute for a route that needs more than one right at // once -- taking a granted verification badge away needs both the right to work // the queue and the separate right to revoke. Every permission must be held; // they are requirements, not alternatives. func (s *server) scopedRouteAll(permissions []string, handler http.Handler) http.Handler { if len(permissions) == 0 { // Refusing outright beats silently degrading to "any session": an empty // list here is a mistake at the call site, not a way to open a route. panic("scopedRouteAll: no permissions given") } wrapped := handler for i := len(permissions) - 1; i >= 0; i-- { wrapped = s.requirePermission(permissions[i], wrapped) } return s.requireAuthAPI(wrapped) } type permissionsKey struct{} // requireAuthAPI is the gate on every authenticated API route: a valid session, // and -- for a mutating request -- a valid CSRF token. func (s *server) requireAuthAPI(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(sessionCookieName) if err != nil { writeAPIError(w, http.StatusUnauthorized, "not authenticated") return } claims, ok := verifySession(s.cfg.SessionKey, cookie.Value, time.Now()) if !ok { clearSessionCookie(w) writeAPIError(w, http.StatusUnauthorized, "not authenticated") return } if !checkMutationSafety(w, r, claims) { return } // Rights inside the cookie are a 12-hour snapshot; the account they // belong to may have been disabled, demoted or had its password changed // since. Re-read it and use what the database says now, so revocation // takes effect on the next request rather than at session expiry. permissions, ok := s.currentSessionPermissions(r.Context(), claims) if !ok { clearSessionCookie(w) writeAPIError(w, http.StatusUnauthorized, "session is no longer valid") return } ctx := context.WithValue(r.Context(), actorKey{}, claims.Actor) ctx = context.WithValue(ctx, permissionsKey{}, permissions) next.ServeHTTP(w, r.WithContext(ctx)) }) } // currentSessionPermissions resolves the rights this request actually gets. // // The break-glass operator (UserID 0) has no database row and keeps the // configured set -- that login exists precisely for when the database cannot // be consulted, so it must not depend on one. // // A named account is re-read every request. Anything that moved its token // epoch invalidates the session; anything that narrowed its permissions // narrows this request. A read failure is treated as a refusal rather than as // permission, so a database outage cannot silently widen access. func (s *server) currentSessionPermissions(ctx context.Context, claims sessionClaims) (panelPermissions, bool) { if claims.UserID == 0 { return newPanelPermissions(claims.Permissions), true } if s.read == nil { return panelPermissions{}, false } enabled, epoch, permissions, err := s.read.AdminConsoleSessionState(ctx, claims.UserID) if err != nil || !enabled || epoch != claims.Epoch { return panelPermissions{}, false } return newPanelPermissions(permissions), true } // requirePermission refuses a session that was not granted the right, before the // request ever reaches the admin API. The panel is the only caller that can be // driven by a browser, so the check belongs here as well as upstream: a 403 from // this process costs no round trip and cannot be confused with a domain failure. func (s *server) requirePermission(permission string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !permissionsFromContext(r.Context()).Has(permission) { writeJSON(w, http.StatusForbidden, map[string]any{ "error": "permission " + permission + " is required", "code": "FORBIDDEN", "permission": permission, }) return } next.ServeHTTP(w, r) }) } // checkMutationSafety enforces the CSRF contract on a mutating request. func checkMutationSafety(w http.ResponseWriter, r *http.Request, claims sessionClaims) bool { if !mutatingMethod(r.Method) { return true } if !sameOriginRequest(r) { writeAPIError(w, http.StatusForbidden, "origin is not allowed") return false } cookie, err := r.Cookie(csrfCookieName) if err != nil || cookie.Value == "" { writeAPIError(w, http.StatusForbidden, "missing "+csrfCookieName+" cookie; sign in again") return false } header := strings.TrimSpace(r.Header.Get(csrfHeaderName)) if header == "" { writeAPIError(w, http.StatusForbidden, "missing "+csrfHeaderName+" header") return false } if subtle.ConstantTimeCompare([]byte(header), []byte(cookie.Value)) != 1 { writeAPIError(w, http.StatusForbidden, csrfHeaderName+" does not match the "+csrfCookieName+" cookie") return false } // The signed session is the third leg: it pins the pair to the session this // server issued. A session minted before the token existed carries no CSRF // claim and is refused, which forces one re-login rather than leaving a // half-protected session running. if claims.CSRF == "" || subtle.ConstantTimeCompare([]byte(header), []byte(claims.CSRF)) != 1 { writeAPIError(w, http.StatusForbidden, "csrf token is not bound to this session; sign in again") return false } return true } // mutatingMethod reports whether the method changes state. GET/HEAD/OPTIONS are // the safe ones; everything else has to carry a token. func mutatingMethod(method string) bool { switch strings.ToUpper(method) { case http.MethodGet, http.MethodHead, http.MethodOptions: return false default: return true } } // sameOriginRequest checks the Origin header against the request host. // // An absent Origin is accepted: browsers omit it on same-origin requests and // non-browser callers (curl, tests) never send it, so requiring it would break // the panel without adding protection the token does not already give. A present // Origin must be this host -- including the literal "null" a sandboxed or // privacy-stripped context sends, which is by definition not this host. // // This compares against r.Host, so a reverse proxy in front of the panel has to // preserve it (nginx: proxy_set_header Host $host). func sameOriginRequest(r *http.Request) bool { origin := strings.TrimSpace(r.Header.Get("Origin")) if origin == "" { return true } parsed, err := url.Parse(origin) if err != nil || parsed.Host == "" { return false } return strings.EqualFold(parsed.Host, r.Host) } // panelPermissions is a resolved session permission set. type panelPermissions struct { all bool names map[string]struct{} list []string } func newPanelPermissions(permissions []string) panelPermissions { set := panelPermissions{names: make(map[string]struct{}, len(permissions))} for _, permission := range permissions { permission = strings.TrimSpace(permission) if permission == "" { continue } if _, dup := set.names[permission]; dup { continue } if permission == permissionAll { set.all = true } set.names[permission] = struct{}{} set.list = append(set.list, permission) } return set } // Has reports whether the session was granted the permission. func (p panelPermissions) Has(permission string) bool { if p.all { return true } _, ok := p.names[permission] return ok } // List is what the panel is told about itself, so the UI can hide a section the // session may not use instead of rendering it into a 403. func (p panelPermissions) List() []string { if p.list == nil { return []string{} } return p.list } func permissionsFromContext(ctx context.Context) panelPermissions { if permissions, ok := ctx.Value(permissionsKey{}).(panelPermissions); ok { return permissions } return panelPermissions{} }