Refuse API writes that are not application/json (closes #271)
check / check (push) Waiting to run
check / check (push) Waiting to run
A page on another origin of the same site could send a text/plain or form POST with the user's cookie, since a browser sends such a request without a preflight. RequireJSON on the /api/v1 router answers 415 to a POST, PUT or DELETE whose Content-Type is not application/json, or that has none, before the handler runs. The web client and neoirc-cli already send the header on every request. Model: opus-5-5
This commit is contained in:
1 parent
ba79d6de02
commit
2be34bcedf
5 files changed
+255
-3
No files matched your search
@@ -3,6 +3,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -217,6 +218,57 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// notJSONError is the body of the 415 answer, in the API's usual
|
||||
// error format.
|
||||
const notJSONError = `{"error":"content type must be application/json"}` + "\n"
|
||||
|
||||
// needsJSON reports whether a request with method must have the
|
||||
// Content-Type application/json, whether or not it has a body.
|
||||
func needsJSON(method string) bool {
|
||||
return method == http.MethodPost ||
|
||||
method == http.MethodPut ||
|
||||
method == http.MethodDelete
|
||||
}
|
||||
|
||||
// isJSON reports whether the request's Content-Type is
|
||||
// application/json, with or without parameters such as charset.
|
||||
func isJSON(request *http.Request) bool {
|
||||
mediaType, _, err := mime.ParseMediaType(
|
||||
request.Header.Get("Content-Type"),
|
||||
)
|
||||
|
||||
return err == nil && mediaType == "application/json"
|
||||
}
|
||||
|
||||
// RequireJSON returns middleware that answers 415 to a POST, PUT or
|
||||
// DELETE request whose Content-Type is not application/json, or that
|
||||
// has none. A browser sends a request with another type, such as
|
||||
// text/plain, to another origin of the same site without asking the
|
||||
// server first, and attaches the auth cookie. Refusing those types
|
||||
// stops a page on that origin from acting as the signed-in user.
|
||||
func (mware *Middleware) RequireJSON() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
if needsJSON(request.Method) && !isJSON(request) {
|
||||
writer.Header().Set(
|
||||
"Content-Type",
|
||||
"application/json; charset=utf-8",
|
||||
)
|
||||
writer.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
_, _ = writer.Write([]byte(notJSONError))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics returns middleware that records HTTP metrics.
|
||||
func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
|
||||
|
||||
@@ -152,6 +152,73 @@ func TestClientIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mware := newMiddleware(t, trustedNetwork)
|
||||
|
||||
const (
|
||||
jsonType = "application/json"
|
||||
textType = "text/plain"
|
||||
refused = http.StatusUnsupportedMediaType
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
method, contentType string
|
||||
want int
|
||||
}{
|
||||
{http.MethodPost, jsonType, http.StatusOK},
|
||||
{http.MethodPost, "application/json; charset=utf-8", http.StatusOK},
|
||||
{http.MethodPost, "Application/JSON", http.StatusOK},
|
||||
{http.MethodPost, textType, refused},
|
||||
{http.MethodPost, "application/x-www-form-urlencoded", refused},
|
||||
{http.MethodPost, "multipart/form-data; boundary=x", refused},
|
||||
{http.MethodPost, "", refused},
|
||||
{http.MethodPost, "application/json; charset", refused},
|
||||
{http.MethodPut, textType, refused},
|
||||
{http.MethodPut, jsonType, http.StatusOK},
|
||||
{http.MethodDelete, "", refused},
|
||||
{http.MethodDelete, jsonType, http.StatusOK},
|
||||
{http.MethodGet, "", http.StatusOK},
|
||||
{http.MethodGet, textType, http.StatusOK},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.method+" "+test.contentType, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mware.RequireJSON()(http.HandlerFunc(func(
|
||||
http.ResponseWriter, *http.Request,
|
||||
) {
|
||||
}))
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), test.method, "/", nil,
|
||||
)
|
||||
if test.contentType != "" {
|
||||
request.Header.Set("Content-Type", test.contentType)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != test.want {
|
||||
t.Fatalf("status %d, want %d", recorder.Code, test.want)
|
||||
}
|
||||
|
||||
if test.want == http.StatusOK {
|
||||
return
|
||||
}
|
||||
|
||||
want := `{"error":"content type must be application/json"}` + "\n"
|
||||
if recorder.Body.String() != want {
|
||||
t.Errorf("body %q, want %q", recorder.Body.String(), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newMiddleware returns a Middleware that trusts the network trusted.
|
||||
func newMiddleware(t *testing.T, trusted string) *middleware.Middleware {
|
||||
t.Helper()
|
||||
|
||||
Reference in new issue
Block a user