package api import ( "fmt" "net" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/go-pkgz/auth/v2/token" R "github.com/go-pkgz/rest" "github.com/go-pkgz/routegroup" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/umputun/remark42/backend/app/rest" "github.com/umputun/remark42/backend/app/store" ) // routes() wraps bounded routes with the enforcing rest.Timeout and deliberately leaves the // streaming/long-polling routes (GET /export, /userdata, /wait) without it. This checks that // contract holds against the vendored middleware: a slow handler under R.Timeout is aborted with // 504 at the deadline, while a route left without it runs to completion. func TestRouteTimeout(t *testing.T) { slow := func(d time.Duration) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { select { case <-r.Context().Done(): // return promptly once the enforcing timeout cancels the context case <-time.After(d): } w.WriteHeader(http.StatusOK) } } router := routegroup.New(http.NewServeMux()) router.With(R.Timeout(20*time.Millisecond)).HandleFunc("GET /bounded", slow(time.Second)) router.HandleFunc("GET /streaming", slow(30*time.Millisecond)) // no timeout, like /export and /wait ts := httptest.NewServer(router) defer ts.Close() resp, err := http.Get(ts.URL + "/bounded") require.NoError(t, err) require.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode, "route under R.Timeout is aborted at the deadline") resp, err = http.Get(ts.URL + "/streaming") require.NoError(t, err) require.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode, "route without R.Timeout runs to completion") } // TestRateLimiter covers the middleware guarding every route group: a burst past the per-second // allowance is refused with 429, and a client under the allowance is not. The limiter keys on // RemoteAddr, so the two cases use different ones rather than waiting for a bucket to refill. func TestRateLimiter(t *testing.T) { router := routegroup.New(http.NewServeMux()) router.With(rateLimiter(1)).HandleFunc("GET /limited", func(http.ResponseWriter, *http.Request) {}) ts := httptest.NewServer(router) defer ts.Close() call := func(remoteAddr string) int { req := httptest.NewRequest("GET", "http://example.com/limited", http.NoBody) req.RemoteAddr = remoteAddr w := httptest.NewRecorder() router.ServeHTTP(w, req) resp := w.Result() assert.NoError(t, resp.Body.Close()) return resp.StatusCode } // one request a second is allowed, so the first of a burst passes and the rest are refused assert.Equal(t, http.StatusOK, call("1.2.3.4:1000"), "first request within the allowance") refused := 0 for range 5 { if call("1.2.3.4:1000") == http.StatusTooManyRequests { refused++ } } assert.Equal(t, 5, refused, "burst past the allowance is refused") // a different client has its own bucket and is unaffected assert.Equal(t, http.StatusOK, call("5.6.7.8:1000"), "limit is per client, not global") } func TestRealIPMiddleware(t *testing.T) { // call runs mw with the given peer and (optional) X-Real-IP header and returns what the // downstream handler observes; state is per-call, so subtests don't share closure locals. call := func(mw func(http.Handler) http.Handler, remoteAddr, xRealIP string) (addr, hdr string) { next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { addr, hdr = r.RemoteAddr, r.Header.Get("X-Real-IP") }) req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) req.RemoteAddr = remoteAddr if xRealIP != "" { req.Header.Set("X-Real-IP", xRealIP) } mw(next).ServeHTTP(httptest.NewRecorder(), req) return addr, hdr } trusted, err := ParseTrustedProxies([]string{"172.16.0.0/12", "2001:db8::/32"}) require.NoError(t, err) t.Run("no trusted proxies trusts the header from anyone (legacy)", func(t *testing.T) { addr, _ := call(realIPMiddleware(nil), "203.0.113.9:1234", "8.8.8.8") assert.Equal(t, "8.8.8.8", addr) }) t.Run("trusted v4 peer: forwarding header sets the client IP", func(t *testing.T) { addr, _ := call(realIPMiddleware(trusted), "172.18.0.5:5555", "8.8.8.8") assert.Equal(t, "8.8.8.8", addr) }) t.Run("trusted v6 peer: forwarding header honored", func(t *testing.T) { addr, _ := call(realIPMiddleware(trusted), "[2001:db8::5]:5555", "8.8.8.8") assert.Equal(t, "8.8.8.8", addr) }) t.Run("trusted peer without a forwarding header falls back to the socket IP", func(t *testing.T) { addr, _ := call(realIPMiddleware(trusted), "172.18.0.5:5555", "") assert.Equal(t, "172.18.0.5", addr, "no header to honor, so the bare socket IP is used") }) t.Run("untrusted peer: header stripped, RemoteAddr pinned to bare socket IP", func(t *testing.T) { addr, hdr := call(realIPMiddleware(trusted), "203.0.113.9:1234", "8.8.8.8") assert.Equal(t, "203.0.113.9", addr, "real socket IP with the port stripped") assert.Empty(t, hdr, "spoofed forwarding header removed so nothing downstream can read it") }) t.Run("unparseable RemoteAddr is treated as untrusted, header stripped", func(t *testing.T) { addr, hdr := call(realIPMiddleware(trusted), "garbage", "8.8.8.8") assert.Equal(t, "garbage", addr, "unparseable peer left as-is, not overwritten") assert.Empty(t, hdr, "forwarding header still stripped for a non-trusted peer") }) } func TestParseTrustedProxies(t *testing.T) { t.Run("cidr, bare v4, bare v6, blanks", func(t *testing.T) { got, err := ParseTrustedProxies([]string{"172.16.0.0/12", " 10.0.0.1 ", "", "2001:db8::/32"}) require.NoError(t, err) require.Len(t, got, 3) assert.True(t, got[0].Contains(net.ParseIP("172.18.0.5"))) assert.True(t, got[1].Contains(net.ParseIP("10.0.0.1"))) assert.False(t, got[1].Contains(net.ParseIP("10.0.0.2")), "a bare IP is a single host") assert.True(t, got[2].Contains(net.ParseIP("2001:db8::1"))) }) t.Run("v4-mapped IPv6 bare entry resolves to the v4 host", func(t *testing.T) { got, err := ParseTrustedProxies([]string{"::ffff:10.0.0.1"}) require.NoError(t, err) require.Len(t, got, 1) assert.True(t, got[0].Contains(net.ParseIP("10.0.0.1")), "the intended /32 host") assert.False(t, got[0].Contains(net.ParseIP("10.0.0.2")), "not a wider range") }) t.Run("malformed entry is a hard error", func(t *testing.T) { _, err := ParseTrustedProxies([]string{"172.16.0.0/12", "nonsense"}) require.Error(t, err) _, err = ParseTrustedProxies([]string{"10.0.0.0/999"}) require.Error(t, err) }) t.Run("all blank yields nil", func(t *testing.T) { got, err := ParseTrustedProxies([]string{"", " "}) require.NoError(t, err) assert.Empty(t, got) }) } func TestTrustsAnyPeer(t *testing.T) { catchAll := func(entries ...string) bool { cidrs, err := ParseTrustedProxies(entries) require.NoError(t, err) return TrustsAnyPeer(cidrs) } assert.True(t, catchAll("10.0.0.0/8", "0.0.0.0/0"), "v4 catch-all") assert.True(t, catchAll("::/0"), "v6 catch-all") assert.False(t, catchAll("172.16.0.0/12", "10.0.0.5"), "scoped ranges are not catch-all") assert.False(t, catchAll(), "empty is not catch-all") } func TestRest_rejectAnonUser(t *testing.T) { ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, "Hello") })))) defer ts.Close() resp, err := http.Get(ts.URL) require.NoError(t, err) require.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "use not logged in") resp, err = http.Get(ts.URL + "?fake_id=anonymous_user123&fake_name=test") require.NoError(t, err) require.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusForbidden, resp.StatusCode, "anon rejected") resp, err = http.Get(ts.URL + "?fake_id=real_user123&fake_name=test") require.NoError(t, err) require.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode, "real user") } func TestRest_cacheControl(t *testing.T) { tbl := []struct { url string version string exp time.Duration etag string maxAge int }{ {"http://example.com/foo", "v1", time.Hour, "b433be1ea19edaee9dc92ca4b895b6bdf3c058cb", 3600}, {"http://example.com/foo2", "v1", 10 * time.Hour, "6d8466aef3246c1057452561acddf7ad9d0d99e0", 36000}, {"http://example.com/foo", "v2", time.Hour, "481700c52aab0dfbca99f3ffc2a4fbb27884c114", 3600}, {"https://example.com/foo", "v2", time.Hour, "bebd4f1b87f474792c4e75e5affe31fbf67f5778", 3600}, } for i, tt := range tbl { t.Run(strconv.Itoa(i), func(t *testing.T) { req := httptest.NewRequest("GET", tt.url, http.NoBody) w := httptest.NewRecorder() h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) h.ServeHTTP(w, req) resp := w.Result() assert.Equal(t, http.StatusOK, resp.StatusCode) assert.NoError(t, resp.Body.Close()) t.Logf("%+v", resp.Header) assert.Equal(t, `"`+tt.etag+`"`, resp.Header.Get("Etag")) assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds()))+", no-cache", resp.Header.Get("Cache-Control")) }) } } // TestRest_apiCSP locks in that /api/v1/* responses get a strict default-src 'none' // override regardless of what the global CSP allows. The widget HTML pages // (/web/*.html) still get the global CSP (with 'unsafe-inline' for bootstrap), // so the test asserts the two policies diverge across origins. func TestRest_apiCSP(t *testing.T) { ts, _, teardown := startupT(t) defer teardown() client := http.Client{} // JSON API endpoint — must carry the strict policy resp, err := client.Get(ts.URL + "/api/v1/config") require.NoError(t, err) defer resp.Body.Close() csp := resp.Header.Get("Content-Security-Policy") assert.Contains(t, csp, "default-src 'none'", "API responses must override the global CSP with default-src 'none'; got %q", csp) assert.Contains(t, csp, "sandbox", "API CSP must include sandbox; got %q", csp) assert.NotContains(t, csp, "'unsafe-inline'", "API CSP must not allow inline scripts/styles; got %q", csp) // RSS/XML endpoint — same strict policy, and the XML response itself must still be served respRSS, err := client.Get(ts.URL + "/api/v1/rss/site?site=remark42") require.NoError(t, err) defer respRSS.Body.Close() assert.Equal(t, http.StatusOK, respRSS.StatusCode, "RSS must still respond OK under strict CSP") cspRSS := respRSS.Header.Get("Content-Security-Policy") assert.Contains(t, cspRSS, "default-src 'none'", "RSS responses must carry the strict API CSP") assert.Contains(t, cspRSS, "sandbox", "RSS CSP must include sandbox") // widget HTML — must keep the global CSP (unchanged, lax to support inline bootstrap) resp2, err := client.Get(ts.URL + "/web/index.html") require.NoError(t, err) defer resp2.Body.Close() csp2 := resp2.Header.Get("Content-Security-Policy") assert.Contains(t, csp2, "'unsafe-inline'", "widget HTML CSP must keep unsafe-inline for bootstrap; got %q", csp2) } // check CSP, img-src should be 'self' with proxy enabled and * without it func TestRest_securityHeaders(t *testing.T) { ts, _, teardown := startupT(t) // with proxy disabled client := http.Client{} resp, err := client.Get(ts.URL + "/web/index.html") require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src *;") assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options")) assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy")) teardown() // check CSP with proxy enabled ts, _, teardown = startupT(t, func(srv *Rest) { srv.ExternalImageProxy = true }) defer teardown() resp, err = client.Get(ts.URL + "/web/index.html") require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src 'self';") assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options")) assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy")) } func TestRest_subscribersOnly(t *testing.T) { paidSubUser := &token.User{} paidSubUser.SetPaidSub(true) tbl := []struct { subsOnly bool user token.User setUser bool status int }{ {true, token.User{}, false, http.StatusUnauthorized}, {true, token.User{}, true, http.StatusForbidden}, {false, token.User{}, false, http.StatusOK}, {false, token.User{}, true, http.StatusOK}, {true, *paidSubUser, true, http.StatusOK}, } for i, tt := range tbl { t.Run(strconv.Itoa(i), func(t *testing.T) { req := httptest.NewRequest("GET", "http://example.com", http.NoBody) if tt.setUser { req = token.SetUserInfo(req, tt.user) } w := httptest.NewRecorder() h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) h.ServeHTTP(w, req) resp := w.Result() assert.Equal(t, tt.status, resp.StatusCode) assert.NoError(t, resp.Body.Close()) }) } } func Test_validEmailAuth(t *testing.T) { tbl := []struct { req string status int }{ {"/auth/email/login?site=remark42&address=umputun%example.com&user=someone", http.StatusOK}, {"/auth/email/login?site=site-with-dash_and_underscore-and.dot&address=umputun%example.com&user=someone", http.StatusOK}, {"/auth/email/login?site=remark42&address=umputun%example.com&user=someone+blah", http.StatusOK}, {"/auth/email/login?site=remark42&address=umputun%example.com&user=Евгений+Умпутун", http.StatusOK}, {"/auth/email/login?site=remark42&address=umputun%example.com&user=12", http.StatusForbidden}, {"/auth/email/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusForbidden}, {"/auth/email/login?site=remark42&address=umputun%example.com&user=someonelooong+loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong", http.StatusForbidden}, {"/auth/twitter/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusOK}, {"/auth/email/login?site=remark42&address=umputun%example.com", http.StatusOK}, {"/auth/email/login?site=remark42&address=umputun+example.com&user=someone", http.StatusForbidden}, {"/auth/email/login?site=bad!site&address=umputun%example.com&user=someone", http.StatusForbidden}, {"/auth/email/login?site=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongsite&address=umputun%example.com&user=someone", http.StatusForbidden}, } for i, tt := range tbl { t.Run(strconv.Itoa(i), func(t *testing.T) { req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody) w := httptest.NewRecorder() h := validEmailAuth()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) h.ServeHTTP(w, req) resp := w.Result() assert.Equal(t, tt.status, resp.StatusCode) assert.NoError(t, resp.Body.Close()) }) } } // TestRest_matchSiteID reproduces the multi-tenant isolation gap in the matchSiteID // middleware. Before the fix, the check `if siteID != "" && user.SiteID != siteID` // silently allowed any authenticated request that omitted the ?site= query param. // On admin and user-mutation routes this meant the cross-site check was bypassable // just by dropping the parameter. The fix requires ?site= to be present and to match // the user's bound site. func TestRest_matchSiteID(t *testing.T) { wrapped := matchSiteID(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) })) cases := []struct { name string userSite string query string want int }{ {name: "matching site allowed", userSite: "site-a", query: "?site=site-a", want: http.StatusOK}, {name: "mismatched site forbidden", userSite: "site-a", query: "?site=site-b", want: http.StatusForbidden}, {name: "missing site param rejected", userSite: "site-a", query: "", want: http.StatusForbidden}, {name: "empty site param rejected", userSite: "site-a", query: "?site=", want: http.StatusForbidden}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r = rest.SetUserInfo(r, store.User{ID: "u", Name: "u", SiteID: c.userSite}) wrapped.ServeHTTP(w, r) }) ts := httptest.NewServer(h) defer ts.Close() resp, err := http.Get(ts.URL + c.query) require.NoError(t, err) require.NoError(t, resp.Body.Close()) assert.Equal(t, c.want, resp.StatusCode) }) } } func TestCorsMiddleware(t *testing.T) { h := corsMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) t.Run("credentialed cross-origin reflects the request origin", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) req.Header.Set("Origin", "https://example.com") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) // AllowedOrigins "*" with credentials must reflect the origin, never a literal "*" assert.Equal(t, "https://example.com", rec.Header().Get("Access-Control-Allow-Origin")) assert.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials")) assert.Equal(t, "Authorization", rec.Header().Get("Access-Control-Expose-Headers")) }) t.Run("preflight advertises configured methods, headers and max-age", func(t *testing.T) { req := httptest.NewRequest(http.MethodOptions, "/", http.NoBody) req.Header.Set("Origin", "https://example.com") req.Header.Set("Access-Control-Request-Method", "POST") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) assert.Equal(t, http.StatusNoContent, rec.Code) assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "POST") assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "X-JWT") assert.Equal(t, "300", rec.Header().Get("Access-Control-Max-Age")) // preflight responses must vary on origin and the request method/headers so caches // don't reuse one preflight across different requests vary := rec.Header().Values("Vary") assert.Contains(t, vary, "Origin") assert.Contains(t, vary, "Access-Control-Request-Method") assert.Contains(t, vary, "Access-Control-Request-Headers") }) t.Run("same-origin request (no Origin) gets no CORS headers", func(t *testing.T) { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", http.NoBody)) assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin")) }) }