package webapi
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mk6i/open-oscar-server/state"
"github.com/mk6i/open-oscar-server/wire"
)
func newTestMiddleware() *AuthMiddleware {
return NewAuthMiddleware(discardLogger())
}
// The Web AIM client permanently downgrades to JSONP when a cross-origin
// response arrives without Access-Control-Allow-Origin: it reads the response
// the browser blocked as a status-0 empty one, and aim.client.js onXhrFailed_
// clears its useXhr flag and never sets it again. One uncovered response is
// enough to latch that, so the CORS layer wraps the whole mux and every layer
// below it — routed, rejected, and unrouted alike — answers with the header.
//
// These cases go through the handler the listener actually serves, so they
// cover the configured allowlist end to end rather than the options struct.
func TestServer_CORS(t *testing.T) {
// What most cases are configured with; a case overrides it to exercise the
// wildcard and empty modes.
defaultOrigins := []string{"https://ras.dev", "http://ras.dev", "http://localhost:8000"}
tests := []struct {
name string
// nil means defaultOrigins.
allowedOrigins []string
method string
path string
origin string
// Setting reqMethod makes the request a preflight.
reqMethod string
reqHeaders string
wantStatus int
wantOrigin string
wantMethods string
wantHeaders string
wantMaxAge string
}{
// Each layer that can produce a response must carry the header.
{
name: "public auth endpoint", method: http.MethodPost, path: "/auth/clientLogin",
origin: "https://ras.dev", wantStatus: http.StatusBadRequest, wantOrigin: "https://ras.dev",
},
{
name: "session rejection", method: http.MethodGet, path: "/aim/fetchEvents",
origin: "https://ras.dev", wantStatus: http.StatusBadRequest, wantOrigin: "https://ras.dev",
},
{
name: "stub route", method: http.MethodGet, path: "/aim/getData",
origin: "https://ras.dev", wantStatus: http.StatusOK, wantOrigin: "https://ras.dev",
},
{
name: "unrouted 404", method: http.MethodGet, path: "/service/getAttributes/nope",
origin: "https://ras.dev", wantStatus: http.StatusNotFound, wantOrigin: "https://ras.dev",
},
// An origin is the scheme, host and port together, so the allowlist
// admits exactly what it names and nothing adjacent.
{
name: "allowed origin, other scheme", method: http.MethodGet, path: "/aim/getData",
origin: "http://ras.dev", wantStatus: http.StatusOK, wantOrigin: "http://ras.dev",
},
{
name: "allowed origin, other port", method: http.MethodGet, path: "/aim/getData",
origin: "http://localhost:8000", wantStatus: http.StatusOK, wantOrigin: "http://localhost:8000",
},
{
name: "different port is a different origin", method: http.MethodGet, path: "/aim/getData",
origin: "http://localhost", wantStatus: http.StatusOK,
},
{
name: "different scheme is a different origin", method: http.MethodGet, path: "/aim/getData",
origin: "https://localhost:8000", wantStatus: http.StatusOK,
},
{
// The suffix a naive HasSuffix check would wave through.
name: "attacker-registrable suffix", method: http.MethodGet, path: "/aim/getData",
origin: "https://ras.dev.evil.example", wantStatus: http.StatusOK,
},
{
name: "unlisted origin", method: http.MethodGet, path: "/aim/getData",
origin: "http://evil.example", wantStatus: http.StatusOK,
},
{
// A same-origin request sends no Origin, so there is nothing to allow.
name: "no origin header", method: http.MethodGet, path: "/aim/getData",
wantStatus: http.StatusOK,
},
// A preflight is answered by the CORS layer alone: it reaches no route,
// so it needs no aimsid and 404s on no path.
{
name: "preflight GET", method: http.MethodOptions, path: "/im/sendIM",
origin: "https://ras.dev", reqMethod: http.MethodGet,
wantStatus: http.StatusNoContent, wantOrigin: "https://ras.dev",
wantMethods: http.MethodGet, wantMaxAge: "3600",
},
{
// Lowercase, as the Fetch spec requires a browser to send it: the
// CORS layer matches these names byte for byte.
name: "preflight POST with a header", method: http.MethodOptions, path: "/im/sendIM",
origin: "https://ras.dev", reqMethod: http.MethodPost, reqHeaders: "content-type",
wantStatus: http.StatusNoContent, wantOrigin: "https://ras.dev",
wantMethods: http.MethodPost, wantHeaders: "content-type", wantMaxAge: "3600",
},
{
name: "preflight on an unrouted path", method: http.MethodOptions, path: "/totally/unrouted",
origin: "https://ras.dev", reqMethod: http.MethodPost,
wantStatus: http.StatusNoContent, wantOrigin: "https://ras.dev",
wantMethods: http.MethodPost, wantMaxAge: "3600",
},
{
// Every route is a GET or a POST, so anything else is refused with
// no CORS headers and the browser never sends the real request.
name: "preflight PUT refused", method: http.MethodOptions, path: "/im/sendIM",
origin: "https://ras.dev", reqMethod: http.MethodPut, wantStatus: http.StatusNoContent,
},
{
name: "preflight DELETE refused", method: http.MethodOptions, path: "/im/sendIM",
origin: "https://ras.dev", reqMethod: http.MethodDelete, wantStatus: http.StatusNoContent,
},
{
name: "preflight with an unlisted header refused", method: http.MethodOptions, path: "/im/sendIM",
origin: "https://ras.dev", reqMethod: http.MethodGet, reqHeaders: "x-not-allowed",
wantStatus: http.StatusNoContent,
},
// The configured modes.
{
name: "wildcard allows any origin", allowedOrigins: []string{"*"},
method: http.MethodGet, path: "/aim/getData", origin: "http://never.seen.example",
wantStatus: http.StatusOK, wantOrigin: "http://never.seen.example",
},
{
// envconfig splits on commas without trimming.
name: "entries are trimmed", allowedOrigins: []string{" https://ras.dev ", " "},
method: http.MethodGet, path: "/aim/getData", origin: "https://ras.dev",
wantStatus: http.StatusOK, wantOrigin: "https://ras.dev",
},
{
// An unset or empty value means "any origin", same as a lone *.
name: "empty allowlist allows any origin", allowedOrigins: []string{" "},
method: http.MethodGet, path: "/aim/getData", origin: "http://never.seen.example",
wantStatus: http.StatusOK, wantOrigin: "http://never.seen.example",
},
{
name: "unset allowlist allows any origin", allowedOrigins: []string{},
method: http.MethodGet, path: "/aim/getData", origin: "http://never.seen.example",
wantStatus: http.StatusOK, wantOrigin: "http://never.seen.example",
},
{
// A wildcard may also stand in for the port.
name: "port wildcard matches any port", allowedOrigins: []string{"http://localhost:*"},
method: http.MethodGet, path: "/aim/getData", origin: "http://localhost:9999",
wantStatus: http.StatusOK, wantOrigin: "http://localhost:9999",
},
{
name: "port wildcard does not match another host", allowedOrigins: []string{"http://localhost:*"},
method: http.MethodGet, path: "/aim/getData", origin: "http://evil.example:9999",
wantStatus: http.StatusOK,
},
{
// One wildcard per entry, standing in for 0 or more characters.
name: "subdomain wildcard matches", allowedOrigins: []string{"https://*.example.com"},
method: http.MethodGet, path: "/aim/getData", origin: "https://web.example.com",
wantStatus: http.StatusOK, wantOrigin: "https://web.example.com",
},
{
name: "subdomain wildcard does not match another domain", allowedOrigins: []string{"https://*.example.com"},
method: http.MethodGet, path: "/aim/getData", origin: "https://web.evil.example",
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
origins := tt.allowedOrigins
if origins == nil {
origins = defaultOrigins
}
h := testServerHandler(t, origins)
r := httptest.NewRequest(tt.method, tt.path, nil)
if tt.origin != "" {
r.Header.Set("Origin", tt.origin)
}
if tt.reqMethod != "" {
r.Header.Set("Access-Control-Request-Method", tt.reqMethod)
}
if tt.reqHeaders != "" {
r.Header.Set("Access-Control-Request-Headers", tt.reqHeaders)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
assert.Equal(t, tt.wantStatus, w.Code)
// An allowed origin is echoed back rather than answered with "*";
// a rejected one gets no header at all, so the browser blocks it.
assert.Equal(t, tt.wantOrigin, w.Header().Get("Access-Control-Allow-Origin"))
assert.Contains(t, w.Header().Get("Vary"), "Origin")
// Credentials are not enabled, so the browser is never told to send them.
assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials"))
// These answer a preflight and mean nothing on any other response.
assert.Equal(t, tt.wantMethods, w.Header().Get("Access-Control-Allow-Methods"))
assert.Equal(t, tt.wantHeaders, strings.ToLower(w.Header().Get("Access-Control-Allow-Headers")))
assert.Equal(t, tt.wantMaxAge, w.Header().Get("Access-Control-Max-Age"))
})
}
}
// testServerHandler builds the handler the listener actually serves, so the CORS
// layer under test is the one NewServer configures.
func testServerHandler(t *testing.T, allowedOrigins []string) http.Handler {
t.Helper()
handler := Handler{
Logger: slog.Default(),
AllowedOrigins: allowedOrigins,
}
srv := NewServer([]string{"127.0.0.1:0"}, slog.Default(), handler, NewSessionManager())
require.NotEmpty(t, srv.servers)
return srv.servers[0].Handler
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestRequireSession_ReadsAimsidFromPOSTBody(t *testing.T) {
tests := []struct {
name string
body string
contentType string
}{
{
name: "declared form body",
body: "aimsid=abc&message=hello",
contentType: "application/x-www-form-urlencoded",
},
{
// No Content-Type announced; the request is form data all the same.
name: "untyped form body",
body: "aimsid=abc&message=hello",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := newTestMiddleware()
reached := false
h := m.RequireSession(okSessionResolver{},
func(w http.ResponseWriter, r *http.Request, _ *Session) {
reached = true
assert.Equal(t, "hello", param(r, "message"))
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest(http.MethodPost, "/im/sendIM", strings.NewReader(tt.body))
if tt.contentType != "" {
r.Header.Set("Content-Type", tt.contentType)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
assert.True(t, reached, "request was rejected before reaching the handler")
assert.Equal(t, http.StatusOK, w.Code)
})
}
}
// The session layer's own rejections must be JSONP-wrapped too, otherwise a
// client already in JSONP mode gets a script-tag syntax error instead of the
// reason.
func TestAuthErrorsHonorJSONP(t *testing.T) {
m := newTestMiddleware()
t.Run("session error", func(t *testing.T) {
h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *Session) {
t.Fatal("handler should not run")
})
r := httptest.NewRequest(http.MethodGet, "/im/sendIM?c=_callbacks_._x&r=7", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
body := w.Body.String()
assert.True(t, strings.HasPrefix(body, "_callbacks_._x("), "got %s", body)
assert.True(t, strings.HasSuffix(body, ");"), "got %s", body)
assert.Contains(t, body, `"statusCode":400`)
assert.Contains(t, body, `"requestId":"7"`)
// A 4xx would stop the browser executing the script tag.
assert.Equal(t, http.StatusOK, w.Code)
})
t.Run("without a callback the session error keeps its HTTP status", func(t *testing.T) {
h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *Session) {
t.Fatal("handler should not run")
})
r := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Header().Get("Content-Type"), "json")
})
}
// An XML client cannot parse a JSON error, so it reports an unreadable response
// instead of the reason the session layer rejected it.
func TestAuthErrorsHonorXML(t *testing.T) {
m := newTestMiddleware()
h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *Session) {
t.Fatal("handler should not run")
})
t.Run("format in the query string", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?f=xml", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
assert.Contains(t, w.Header().Get("Content-Type"), "xml")
assert.Contains(t, w.Body.String(), "400")
})
// A POST states the format in its body, the only place clientLogin sends it.
t.Run("format in the POST body", func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/auth/clientLogin", strings.NewReader("s=testuser&f=xml"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
assert.Contains(t, w.Header().Get("Content-Type"), "xml")
assert.Contains(t, w.Body.String(), "400")
})
// A client on the