ratelimit.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. package handlers
  2. import (
  3. "fmt"
  4. "log/slog"
  5. "net/http"
  6. "time"
  7. "github.com/mk6i/open-oscar-server/server/webapi/types"
  8. "github.com/mk6i/open-oscar-server/state"
  9. "github.com/mk6i/open-oscar-server/wire"
  10. )
  11. // rateLimitStatusCode is the Web AIM API envelope code for a rate-limited
  12. // request. The AIM web client swallows 430 on the IM path so that the rateLimit
  13. // event owns the user-facing message instead of a generic send failure alert.
  14. const rateLimitStatusCode = 430
  15. // minRetryAfter floors the Retry-After hint sent with a rate-limited response.
  16. // The computed wait can round down to nothing when a class is barely over its
  17. // limit, and a hint of zero invites an immediate retry.
  18. const minRetryAfter = 1 * time.Second
  19. // SessionHandlerFunc is the session-aware handler shape that
  20. // AuthMiddleware.RequireSession invokes once it has resolved an aimsid.
  21. type SessionHandlerFunc = func(http.ResponseWriter, *http.Request, *state.WebAPISession)
  22. // RateLimitMiddleware enforces OSCAR rate limits on Web API routes that reach a
  23. // food group.
  24. //
  25. // Such routes are limited by OSCAR itself: OSCAR charges the session's shared
  26. // per-rate-class budget, the same budget a native OSCAR or TOC client spends, so
  27. // a user cannot dodge a limit by switching transports. Routes that reach no food
  28. // group are not limited here; edge rate limiting (a reverse proxy keyed by client
  29. // IP) is expected to cover the unauthenticated login/asset endpoints and the
  30. // authenticated bookkeeping ones.
  31. //
  32. // It lives alongside the handlers (rather than in the middleware package) so that
  33. // its rejection can be encoded through the same SendResponse path the handlers
  34. // use, honoring the request's JSON/JSONP/XML/AMF format.
  35. //
  36. // It only enforces the limit (the 430 rejection). Telling the client its status
  37. // changed is the job of OServiceService.MonitorRateLimits.
  38. type RateLimitMiddleware struct {
  39. snacRateLimits wire.SNACRateLimits
  40. logger *slog.Logger
  41. }
  42. // NewRateLimitMiddleware creates a RateLimitMiddleware. snacRateLimits is the
  43. // same SNAC-to-rate-class mapping the OSCAR and TOC servers use.
  44. func NewRateLimitMiddleware(snacRateLimits wire.SNACRateLimits, logger *slog.Logger) *RateLimitMiddleware {
  45. return &RateLimitMiddleware{
  46. snacRateLimits: snacRateLimits,
  47. logger: logger,
  48. }
  49. }
  50. // OSCAR returns middleware that charges one unit against the OSCAR rate class
  51. // mapped to (foodGroup, subGroup) before invoking the wrapped handler. It is the
  52. // HTTP counterpart of the TOC server's per-command rate check.
  53. //
  54. // A SNAC with no rate class mapping is allowed through, since refusing traffic
  55. // because the server's own table is incomplete would be worse than not limiting
  56. // it.
  57. func (l *RateLimitMiddleware) OSCAR(foodGroup uint16, subGroup uint16) func(SessionHandlerFunc) SessionHandlerFunc {
  58. return func(next SessionHandlerFunc) SessionHandlerFunc {
  59. return func(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
  60. ctx := r.Context()
  61. rateClassID, ok := l.snacRateLimits.RateClassLookup(foodGroup, subGroup)
  62. if !ok {
  63. l.logger.ErrorContext(ctx, "rate limit not found, allowing request through",
  64. "foodgroup", wire.FoodGroupName(foodGroup),
  65. "subgroup", wire.SubGroupName(foodGroup, subGroup))
  66. next(w, r, session)
  67. return
  68. }
  69. sess := session.OSCARSession.Session()
  70. status := sess.EvaluateRateLimit(time.Now(), rateClassID)
  71. // Disconnect is rejected alongside Limited: EvaluateRateLimit has
  72. // already closed the account's OSCAR session by the time it returns,
  73. // so there is nothing left for the handler to act on. That close also
  74. // invalidates the aimsid (GetSession stops resolving a session whose
  75. // OSCAR instance is closed), so every subsequent request is turned
  76. // away at RequireSession rather than reaching here again.
  77. if status == wire.RateLimitStatusLimited || status == wire.RateLimitStatusDisconnect {
  78. l.logger.DebugContext(ctx, "(webapi) rate limit exceeded, dropping request",
  79. "foodgroup", wire.FoodGroupName(foodGroup),
  80. "subgroup", wire.SubGroupName(foodGroup, subGroup),
  81. "status", rateLimitStatusName(status))
  82. // A disconnected session has no aimsid left to retry with, so
  83. // there is no wait to advertise.
  84. var retryAfter time.Duration
  85. if status == wire.RateLimitStatusLimited {
  86. retryAfter = retryAfterFor(sess.RateLimitStates()[rateClassID-1])
  87. }
  88. l.sendRateLimited(w, r, retryAfter)
  89. return
  90. }
  91. next(w, r, session)
  92. }
  93. }
  94. }
  95. // retryAfterFor returns how long the client must wait for its next request on
  96. // this class to clear the limit.
  97. //
  98. // OSCAR's limiter has no fixed window: it tracks a moving average of the gap
  99. // between requests, and a request lifts the limit only once that average climbs
  100. // back to ClearLevel. Inverting CheckRateLimit's update for the elapsed time that
  101. // lands the new average exactly on ClearLevel gives
  102. //
  103. // elapsed = ClearLevel*WindowSize - CurrentLevel*(WindowSize-1)
  104. //
  105. // A flat hint cannot work here, because a rejected request is still charged: a
  106. // client retrying on a fixed interval drives the average toward that interval, so
  107. // any hint below the class's ClearLevel holds the average just under the bar and
  108. // the client stays limited forever. The production ICBM class clears at 5100ms,
  109. // which a 5s hint would do exactly.
  110. func retryAfterFor(rcs state.RateClassState) time.Duration {
  111. neededMs := int64(rcs.ClearLevel)*int64(rcs.WindowSize) - int64(rcs.CurrentLevel)*int64(rcs.WindowSize-1)
  112. // Retry-After carries whole seconds, so round up: a hint that is short by a
  113. // fraction of a second reproduces the same never-clears loop. A class barely
  114. // over its limit can compute to no wait at all, hence the floor.
  115. return max(time.Duration((neededMs+999)/1000)*time.Second, minRetryAfter)
  116. }
  117. // seedRateLimitAlert raises the client's rate limit alert when a session starts
  118. // on an account that is already rate limited.
  119. //
  120. // The monitor broadcasts transitions, not current state, so a session signing on
  121. // mid-limit missed the one that raised the alert — and the client's alert is
  122. // sticky, so the eventual "clear" would arrive with nothing to dismiss. An OSCAR
  123. // client learns the current state from the rate params it gets at handshake; this
  124. // is the Web API's equivalent.
  125. //
  126. // Only the limited state is seeded: alert is a warning the user cannot act on,
  127. // and seeding clear would render nothing.
  128. func seedRateLimitAlert(session *state.WebAPISession, classID wire.RateLimitClassID) {
  129. if classID == 0 {
  130. return
  131. }
  132. status := session.OSCARSession.Session().RateLimitStates()[classID-1].CurrentStatus
  133. if status != wire.RateLimitStatusLimited {
  134. return
  135. }
  136. session.EventQueue.Push(types.EventTypeRateLimit, types.RateLimitEvent{
  137. Classes: []types.RateLimitClass{
  138. {
  139. ID: int(classID),
  140. Status: rateLimitStatusName(status),
  141. },
  142. },
  143. })
  144. }
  145. // rateLimitStatusName maps an OSCAR rate limit status onto the status string the
  146. // web client switches on. It returns "" for a status the client does not know.
  147. func rateLimitStatusName(status wire.RateLimitStatus) string {
  148. switch status {
  149. case wire.RateLimitStatusClear:
  150. return "clear"
  151. case wire.RateLimitStatusAlert:
  152. return "warn"
  153. case wire.RateLimitStatusLimited:
  154. return "limit"
  155. case wire.RateLimitStatusDisconnect:
  156. return "disconnect"
  157. default:
  158. return ""
  159. }
  160. }
  161. // sendRateLimited writes a rate limit rejection. The transport status is 200 and
  162. // the rejection lives entirely in the Web AIM API envelope's own rate limit code.
  163. //
  164. // The transport status is deliberately not 429: the AIM client's WIM request layer
  165. // (XhrManager) and its Fetcher only parse the response body on a 2xx. A non-2xx is
  166. // routed to their error handlers, which synthesize a generic "request failed"
  167. // result and never look at the body, so the envelope's 430 — which the client
  168. // swallows on the IM path in favor of the rateLimit event — would go unread and the
  169. // user would see a generic send failure instead.
  170. //
  171. // The body is encoded via SendResponse, so it honors the request's format
  172. // (JSON/JSONP/XML/AMF) and echoes the request id into response.requestId — which
  173. // the JSONP fallback needs to correlate the reply, or its UI hangs — exactly as a
  174. // normal handler response would.
  175. //
  176. // A retryAfter of zero sends no Retry-After header, for the rejections that have
  177. // nothing to retry.
  178. func (l *RateLimitMiddleware) sendRateLimited(w http.ResponseWriter, r *http.Request, retryAfter time.Duration) {
  179. if retryAfter > 0 {
  180. w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())))
  181. }
  182. resp := BaseResponse{}
  183. resp.Response.StatusCode = rateLimitStatusCode
  184. resp.Response.StatusText = "rate limit exceeded"
  185. SendResponse(w, r, resp, l.logger)
  186. }