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
257 lines
6.2 KiB
Go
257 lines
6.2 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"testing"
|
|
"time"
|
|
|
|
"go4.org/netipx"
|
|
"sneak.berlin/go/neoirc/internal/config"
|
|
"sneak.berlin/go/neoirc/internal/globals"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
"sneak.berlin/go/neoirc/internal/middleware"
|
|
)
|
|
|
|
// The tests trust 10.0.0.0/8. Clients use the documentation ranges
|
|
// 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24.
|
|
const (
|
|
trustedNetwork = "10.0.0.0/8"
|
|
proxyPeer = "10.0.0.1:4000"
|
|
directPeer = "192.0.2.1:4000"
|
|
client = "203.0.113.7"
|
|
clientWithPort = "203.0.113.7:4000"
|
|
realIPClient = "203.0.113.8"
|
|
)
|
|
|
|
type clientIPTest struct {
|
|
name string
|
|
remoteAddr string
|
|
forwarded []string
|
|
realIP string
|
|
want string
|
|
}
|
|
|
|
func clientIPTests() []clientIPTest {
|
|
return []clientIPTest{
|
|
{
|
|
name: "untrusted peer ignores forged headers",
|
|
remoteAddr: directPeer,
|
|
forwarded: []string{client},
|
|
realIP: realIPClient,
|
|
want: directPeer,
|
|
},
|
|
{name: "trusted peer without headers", remoteAddr: proxyPeer, want: proxyPeer},
|
|
{
|
|
name: "trusted peer gives rightmost untrusted entry",
|
|
remoteAddr: proxyPeer,
|
|
forwarded: []string{"198.51.100.9, 203.0.113.7, 10.0.0.2"},
|
|
want: clientWithPort,
|
|
},
|
|
{
|
|
name: "entries split over several headers",
|
|
remoteAddr: proxyPeer,
|
|
forwarded: []string{"198.51.100.9", client, "10.0.0.2"},
|
|
want: clientWithPort,
|
|
},
|
|
{
|
|
name: "all-trusted chain gives leftmost entry",
|
|
remoteAddr: proxyPeer,
|
|
forwarded: []string{"10.0.0.3, 10.0.0.2"},
|
|
want: "10.0.0.3:4000",
|
|
},
|
|
{
|
|
name: "X-Real-IP without X-Forwarded-For",
|
|
remoteAddr: proxyPeer,
|
|
realIP: realIPClient,
|
|
want: "203.0.113.8:4000",
|
|
},
|
|
{
|
|
name: "X-Forwarded-For wins over X-Real-IP",
|
|
remoteAddr: proxyPeer,
|
|
forwarded: []string{client},
|
|
realIP: realIPClient,
|
|
want: clientWithPort,
|
|
},
|
|
{
|
|
name: "unparseable X-Forwarded-For entry gives peer",
|
|
remoteAddr: proxyPeer,
|
|
forwarded: []string{"203.0.113.7, not-an-ip, 10.0.0.2"},
|
|
want: proxyPeer,
|
|
},
|
|
{
|
|
name: "unparseable entry past the client is not read",
|
|
remoteAddr: proxyPeer,
|
|
forwarded: []string{"not-an-ip, 203.0.113.7"},
|
|
want: clientWithPort,
|
|
},
|
|
{
|
|
name: "unparseable X-Real-IP gives peer",
|
|
remoteAddr: proxyPeer,
|
|
realIP: "not-an-ip",
|
|
want: proxyPeer,
|
|
},
|
|
{
|
|
name: "mapped IPv6 peer is unmapped and trusted",
|
|
remoteAddr: "[::ffff:10.0.0.1]:4000",
|
|
forwarded: []string{client},
|
|
want: clientWithPort,
|
|
},
|
|
{
|
|
name: "mapped IPv6 peer outside the set",
|
|
remoteAddr: "[::ffff:192.0.2.1]:4000",
|
|
forwarded: []string{client},
|
|
want: directPeer,
|
|
},
|
|
{
|
|
name: "IPv6 client",
|
|
remoteAddr: proxyPeer,
|
|
forwarded: []string{"2001:db8::1"},
|
|
want: "[2001:db8::1]:4000",
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestClientIP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mware := newMiddleware(t, trustedNetwork)
|
|
|
|
for _, test := range clientIPTests() {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var got string
|
|
|
|
handler := mware.ClientIP()(http.HandlerFunc(func(
|
|
_ http.ResponseWriter, request *http.Request,
|
|
) {
|
|
got = request.RemoteAddr
|
|
}))
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/", nil,
|
|
)
|
|
request.RemoteAddr = test.remoteAddr
|
|
|
|
for _, value := range test.forwarded {
|
|
request.Header.Add("X-Forwarded-For", value)
|
|
}
|
|
|
|
if test.realIP != "" {
|
|
request.Header.Set("X-Real-IP", test.realIP)
|
|
}
|
|
|
|
handler.ServeHTTP(httptest.NewRecorder(), request)
|
|
|
|
if got != test.want {
|
|
t.Errorf("RemoteAddr = %q, want %q", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
var builder netipx.IPSetBuilder
|
|
|
|
builder.AddPrefix(netip.MustParsePrefix(trusted))
|
|
|
|
trustedSet, err := builder.IPSet()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
globs := &globals.Globals{
|
|
Appname: "neoirc-test",
|
|
Version: "test",
|
|
StartTime: time.Now(),
|
|
}
|
|
|
|
log, err := logger.New(nil, logger.Params{Globals: globs})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mware, err := middleware.New(nil, middleware.Params{
|
|
Logger: log,
|
|
Globals: globs,
|
|
Config: &config.Config{TrustedProxies: trustedSet},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return mware
|
|
}
|