Refuse API writes that are not application/json (closes #271)
check / check (push) Failing after 3s
check / check (push) Failing after 3s
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
@@ -415,7 +415,7 @@ func doRequestAuth(
|
||||
return nil, fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
if method != http.MethodGet {
|
||||
request.Header.Set(
|
||||
"Content-Type", "application/json",
|
||||
)
|
||||
@@ -903,6 +903,131 @@ func TestRequestBodyLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// requestWithType sends a request with the Content-Type contentType,
|
||||
// or none when it is empty, and returns the status and the decoded
|
||||
// JSON body.
|
||||
func (tserver *testServer) requestWithType(
|
||||
method, path, token, contentType, body string,
|
||||
) (int, map[string]any) {
|
||||
tserver.t.Helper()
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
tserver.t.Context(), method, tserver.url(path),
|
||||
strings.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
tserver.t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
request.AddCookie(&http.Cookie{ //nolint:exhaustruct,gosec // only name+value
|
||||
Name: authCookieName,
|
||||
Value: token,
|
||||
})
|
||||
|
||||
resp, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
tserver.t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var result map[string]any
|
||||
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
return resp.StatusCode, result
|
||||
}
|
||||
|
||||
// TestRequireJSONContentType checks that a POST whose Content-Type is
|
||||
// not application/json gets 415 and changes nothing: the message is
|
||||
// not stored and the client is not logged out.
|
||||
func TestRequireJSONContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("alice_ctype")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: "#ctype",
|
||||
})
|
||||
|
||||
status, _ := tserver.requestWithType(
|
||||
http.MethodGet, apiState, token, "text/plain", "",
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("GET with text/plain: expected 200, got %d", status)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name, contentType string
|
||||
want int
|
||||
}{
|
||||
{"text", "text/plain", http.StatusUnsupportedMediaType},
|
||||
{
|
||||
"form", "application/x-www-form-urlencoded",
|
||||
http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
"multipart", "multipart/form-data; boundary=x",
|
||||
http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{"missing", "", http.StatusUnsupportedMediaType},
|
||||
{"json", "application/json", http.StatusOK},
|
||||
{"json with charset", "application/json; charset=utf-8", http.StatusOK},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status, result := tserver.requestWithType(
|
||||
http.MethodPost, apiMessages, token, test.contentType,
|
||||
`{"command":"PRIVMSG","to":"#ctype","body":["`+test.name+`"]}`,
|
||||
)
|
||||
if status != test.want {
|
||||
t.Fatalf("send: expected %d, got %d: %v", test.want, status, result)
|
||||
}
|
||||
|
||||
if status == http.StatusUnsupportedMediaType &&
|
||||
result["error"] != "content type must be application/json" {
|
||||
t.Errorf("send: unexpected error body %v", result)
|
||||
}
|
||||
|
||||
msgs, _ := tserver.pollMessages(token, 0)
|
||||
|
||||
stored := slices.ContainsFunc(msgs, func(msg map[string]any) bool {
|
||||
body, _ := msg[bodyKey].([]any)
|
||||
|
||||
return msg[commandKey] == privmsgCmd &&
|
||||
slices.Contains(body, any(test.name))
|
||||
})
|
||||
if stored != (test.want == http.StatusOK) {
|
||||
t.Errorf("message stored: %v, want %v", stored, !stored)
|
||||
}
|
||||
|
||||
if test.want == http.StatusOK {
|
||||
return
|
||||
}
|
||||
|
||||
status, _ = tserver.requestWithType(
|
||||
http.MethodPost, apiLogout, token, test.contentType, "",
|
||||
)
|
||||
if status != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("logout: expected 415, got %d", status)
|
||||
}
|
||||
|
||||
status, _ = tserver.getState(token)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("state after refused logout: expected 200, got %d", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthNoCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in new issue
Block a user