cors_test.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. package middleware
  2. import (
  3. "context"
  4. "io"
  5. "log/slog"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "github.com/stretchr/testify/assert"
  11. "github.com/stretchr/testify/require"
  12. "github.com/mk6i/open-oscar-server/state"
  13. )
  14. // stubValidator records how many times a key was looked up so the tests can
  15. // assert that CORSMiddleware and the auth layer share a single lookup.
  16. type stubValidator struct {
  17. key *state.WebAPIKey
  18. lookup int
  19. }
  20. func (s *stubValidator) GetAPIKeyByDevKey(_ context.Context, devKey string) (*state.WebAPIKey, error) {
  21. s.lookup++
  22. if s.key == nil || s.key.DevKey != devKey {
  23. return nil, nil
  24. }
  25. return s.key, nil
  26. }
  27. func (s *stubValidator) UpdateLastUsed(context.Context, string) error { return nil }
  28. func newTestMiddleware(v APIKeyValidator) *AuthMiddleware {
  29. return NewAuthMiddleware(v, slog.New(slog.NewTextHandler(io.Discard, nil)))
  30. }
  31. // The Web AIM client permanently downgrades to JSONP when a cross-origin
  32. // response arrives without Access-Control-Allow-Origin, so every response the
  33. // auth layer rejects must still carry CORS headers. That only holds while
  34. // CORSMiddleware wraps the auth middleware.
  35. func TestCORSMiddleware_HeadersOnAuthRejection(t *testing.T) {
  36. // The auth layer reports failures in the response envelope rather than the
  37. // HTTP status, so the envelope's statusCode is what identifies a rejection.
  38. tests := []struct {
  39. name string
  40. query string
  41. validator *stubValidator
  42. wantEnvelope string
  43. }{
  44. {
  45. name: "missing credentials",
  46. query: "?f=json",
  47. validator: &stubValidator{},
  48. wantEnvelope: `"statusCode":400`,
  49. },
  50. {
  51. name: "unknown api key",
  52. query: "?k=nosuchkey",
  53. validator: &stubValidator{},
  54. wantEnvelope: `"statusCode":403`,
  55. },
  56. }
  57. for _, tt := range tests {
  58. t.Run(tt.name, func(t *testing.T) {
  59. m := newTestMiddleware(tt.validator)
  60. var reachedHandler bool
  61. h := m.CORSMiddleware(m.AuthenticateFlexible(
  62. http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  63. reachedHandler = true
  64. w.WriteHeader(http.StatusOK)
  65. })))
  66. r := httptest.NewRequest(http.MethodGet, "/im/sendIM"+tt.query, nil)
  67. r.Header.Set("Origin", "http://localhost:8000")
  68. w := httptest.NewRecorder()
  69. h.ServeHTTP(w, r)
  70. assert.False(t, reachedHandler, "auth layer should have rejected the request")
  71. assert.Contains(t, w.Body.String(), tt.wantEnvelope)
  72. assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
  73. assert.Equal(t, "Origin", w.Header().Get("Vary"))
  74. })
  75. }
  76. }
  77. // A 404 for an endpoint this server does not implement (/service/getAttributes,
  78. // /metrics/sendIM) must reach the client as a 404 rather than as a blocked
  79. // response.
  80. func TestCORSMiddleware_HeadersOn404(t *testing.T) {
  81. m := newTestMiddleware(&stubValidator{})
  82. h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  83. w.WriteHeader(http.StatusNotFound)
  84. }))
  85. r := httptest.NewRequest(http.MethodGet, "/service/getAttributes?f=json&aimsid=abc", nil)
  86. r.Header.Set("Origin", "http://localhost:8000")
  87. w := httptest.NewRecorder()
  88. h.ServeHTTP(w, r)
  89. assert.Equal(t, http.StatusNotFound, w.Code)
  90. assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
  91. }
  92. func TestCORSMiddleware_PreflightShortCircuits(t *testing.T) {
  93. m := newTestMiddleware(&stubValidator{})
  94. var reachedNext bool
  95. h := m.CORSMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
  96. reachedNext = true
  97. }))
  98. r := httptest.NewRequest(http.MethodOptions, "/im/sendIM", nil)
  99. r.Header.Set("Origin", "http://localhost:8000")
  100. w := httptest.NewRecorder()
  101. h.ServeHTTP(w, r)
  102. assert.Equal(t, http.StatusNoContent, w.Code)
  103. assert.False(t, reachedNext, "preflight must not reach the wrapped handler")
  104. assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
  105. assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "POST")
  106. }
  107. // CORSMiddleware runs ahead of authentication and so resolves the API key
  108. // itself; the auth layer behind it must reuse that lookup rather than repeat it.
  109. func TestCORSMiddleware_SharesKeyLookupWithAuth(t *testing.T) {
  110. v := &stubValidator{key: &state.WebAPIKey{
  111. DevID: "dev1",
  112. DevKey: "goodkey",
  113. IsActive: true,
  114. RateLimit: 100,
  115. }}
  116. m := newTestMiddleware(v)
  117. var served bool
  118. h := m.CORSMiddleware(m.Authenticate(
  119. http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  120. served = true
  121. key, ok := r.Context().Value(ContextKeyAPIKey).(*state.WebAPIKey)
  122. require.True(t, ok, "handler should see the validated key")
  123. assert.Equal(t, "dev1", key.DevID)
  124. w.WriteHeader(http.StatusOK)
  125. })))
  126. r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?k=goodkey", nil)
  127. r.Header.Set("Origin", "http://localhost:8000")
  128. w := httptest.NewRecorder()
  129. h.ServeHTTP(w, r)
  130. assert.True(t, served)
  131. assert.Equal(t, http.StatusOK, w.Code)
  132. assert.Equal(t, 1, v.lookup, "key should be resolved once per request, not once per middleware")
  133. }
  134. // Per-key origin allowlists must keep working now that the origin decision is
  135. // made before authentication.
  136. func TestCORSMiddleware_PerKeyOriginAllowlist(t *testing.T) {
  137. v := &stubValidator{key: &state.WebAPIKey{
  138. DevID: "dev1",
  139. DevKey: "goodkey",
  140. IsActive: true,
  141. RateLimit: 100,
  142. AllowedOrigins: []string{"http://allowed.example"},
  143. }}
  144. m := newTestMiddleware(v)
  145. h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  146. w.WriteHeader(http.StatusOK)
  147. }))
  148. t.Run("allowed origin", func(t *testing.T) {
  149. r := httptest.NewRequest(http.MethodGet, "/im/sendIM?k=goodkey", nil)
  150. r.Header.Set("Origin", "http://allowed.example")
  151. w := httptest.NewRecorder()
  152. h.ServeHTTP(w, r)
  153. assert.Equal(t, "http://allowed.example", w.Header().Get("Access-Control-Allow-Origin"))
  154. })
  155. t.Run("disallowed origin", func(t *testing.T) {
  156. r := httptest.NewRequest(http.MethodGet, "/im/sendIM?k=goodkey", nil)
  157. r.Header.Set("Origin", "http://evil.example")
  158. w := httptest.NewRecorder()
  159. h.ServeHTTP(w, r)
  160. assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
  161. })
  162. }
  163. // A POST body must survive the middleware chain: CORSMiddleware reads the API
  164. // key from the query string only, so it never parses the form.
  165. func TestCORSMiddleware_DoesNotConsumePOSTBody(t *testing.T) {
  166. m := newTestMiddleware(&stubValidator{})
  167. h := m.CORSMiddleware(m.AuthenticateFlexible(
  168. http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  169. body, err := io.ReadAll(r.Body)
  170. require.NoError(t, err)
  171. assert.Equal(t, "message=hello", string(body))
  172. w.WriteHeader(http.StatusOK)
  173. })))
  174. r := httptest.NewRequest(http.MethodPost, "/im/sendIM?aimsid=abc", strings.NewReader("message=hello"))
  175. r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
  176. r.Header.Set("Origin", "http://localhost:8000")
  177. w := httptest.NewRecorder()
  178. h.ServeHTTP(w, r)
  179. assert.Equal(t, http.StatusOK, w.Code)
  180. }
  181. // The auth layer's own rejections must be JSONP-wrapped too, otherwise a client
  182. // already in JSONP mode gets a script-tag syntax error instead of the reason.
  183. func TestAuthErrorsHonorJSONP(t *testing.T) {
  184. m := newTestMiddleware(&stubValidator{})
  185. t.Run("session error", func(t *testing.T) {
  186. h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *state.WebAPISession) {
  187. t.Fatal("handler should not run")
  188. })
  189. r := httptest.NewRequest(http.MethodGet, "/im/sendIM?c=_callbacks_._x&r=7", nil)
  190. w := httptest.NewRecorder()
  191. h.ServeHTTP(w, r)
  192. body := w.Body.String()
  193. assert.True(t, strings.HasPrefix(body, "_callbacks_._x("), "got %s", body)
  194. assert.True(t, strings.HasSuffix(body, ");"), "got %s", body)
  195. assert.Contains(t, body, `"statusCode":400`)
  196. assert.Contains(t, body, `"requestId":"7"`)
  197. // A 4xx would stop the browser executing the script tag.
  198. assert.Equal(t, http.StatusOK, w.Code)
  199. })
  200. t.Run("missing credentials", func(t *testing.T) {
  201. h := m.AuthenticateFlexible(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
  202. t.Fatal("handler should not run")
  203. }))
  204. r := httptest.NewRequest(http.MethodGet, "/im/sendIM?c=cb&r=9", nil)
  205. w := httptest.NewRecorder()
  206. h.ServeHTTP(w, r)
  207. body := w.Body.String()
  208. assert.True(t, strings.HasPrefix(body, "cb("), "got %s", body)
  209. assert.Contains(t, body, `"statusCode":400`)
  210. assert.Contains(t, body, `"requestId":"9"`)
  211. assert.Equal(t, http.StatusOK, w.Code)
  212. })
  213. t.Run("without a callback the session error keeps its HTTP status", func(t *testing.T) {
  214. h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *state.WebAPISession) {
  215. t.Fatal("handler should not run")
  216. })
  217. r := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
  218. w := httptest.NewRecorder()
  219. h.ServeHTTP(w, r)
  220. assert.Equal(t, http.StatusBadRequest, w.Code)
  221. assert.Contains(t, w.Header().Get("Content-Type"), "json")
  222. })
  223. }
  224. // stubSessionResolver never resolves a session, so RequireSession always rejects.
  225. type stubSessionResolver struct{}
  226. func (stubSessionResolver) GetSession(context.Context, string) (*state.WebAPISession, error) {
  227. return nil, assert.AnError
  228. }
  229. func (stubSessionResolver) TouchSession(context.Context, string) error { return nil }