fix(server,web): auth-gate client APK + version endpoints + per-user rate limit (#397)
Closes the bandwidth-abuse vector on the in-app update flow. Both
endpoints now sit inside the authed.Group; APK additionally gets a
60s/user rate limit to suppress accidental hammering or scripted
abuse.
### Server
- internal/api/client_assets.go:
- clientAPKAllowDownload(): in-memory map[userID]time.Time under
a mutex. Returns 0 (allow) or wait duration (block).
- handleClientAPK reads user from context, checks the limit,
returns 429 + Retry-After header if blocked.
- testResetClientAPKRateLimit() lets tests start clean.
- internal/api/api.go: routes moved from the root /api group into
the authed.Group block (alongside /quarantine, /requests, etc.).
- Tests: added TestClientAPK_401WhenUnauthenticated and
TestClientAPK_RateLimit_429OnRapidSecondCall (also verifies
different user gets a fresh slot). Existing tests updated to use
authedRequest() helper.
### Web
- MobileAppDownload.svelte: switched from bare fetch (no credentials)
to api.get<>() which carries the session cookie. 404 / 401 /
network errors all silently hide the download row.
- Removed the login-page mount entirely — pre-auth surfaces should
never show this. Settings → Mobile app section keeps it for
logged-in users.
Flutter unaffected: dio's Bearer interceptor already attaches the
token, and the polling only fires once the post-login shell mounts
the banner widget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -9,8 +10,33 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// authedRequest builds a request with a fake user in context, mirroring
|
||||
// what the auth.RequireUser middleware injects in production.
|
||||
// userIDByte fills every byte of the user's UUID, so two requests with
|
||||
// different userIDByte values get distinct rate-limit slots.
|
||||
func authedRequest(method, path string, userIDByte byte) *http.Request {
|
||||
user := dbq.User{
|
||||
ID: pgtype.UUID{
|
||||
Bytes: [16]byte{userIDByte, userIDByte, userIDByte, userIDByte,
|
||||
userIDByte, userIDByte, userIDByte, userIDByte,
|
||||
userIDByte, userIDByte, userIDByte, userIDByte,
|
||||
userIDByte, userIDByte, userIDByte, userIDByte},
|
||||
Valid: true,
|
||||
},
|
||||
Username: "tester",
|
||||
}
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
ctx := context.WithValue(r.Context(), auth.UserCtxKeyForTest(), user)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// withClientAPKDir points the handlers at a fresh temp dir for each
|
||||
// test and restores the env var on cleanup. Returns the dir.
|
||||
func withClientAPKDir(t *testing.T) string {
|
||||
@@ -81,11 +107,26 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPK_404WhenMissing(t *testing.T) {
|
||||
func TestClientAPK_401WhenUnauthenticated(t *testing.T) {
|
||||
withClientAPKDir(t)
|
||||
testResetClientAPKRateLimit()
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rr := httptest.NewRecorder()
|
||||
// No user in context — middleware would block in prod; handler also
|
||||
// rejects defensively in case the route ever lands outside the
|
||||
// authed group by accident.
|
||||
h.handleClientAPK(rr, httptest.NewRequest(http.MethodGet, "/api/client/apk", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("want 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPK_404WhenMissing(t *testing.T) {
|
||||
withClientAPKDir(t)
|
||||
testResetClientAPKRateLimit()
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleClientAPK(rr, authedRequest(http.MethodGet, "/api/client/apk", 0x01))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("want 404, got %d", rr.Code)
|
||||
}
|
||||
@@ -93,13 +134,14 @@ func TestClientAPK_404WhenMissing(t *testing.T) {
|
||||
|
||||
func TestClientAPK_StreamsWithCorrectContentType(t *testing.T) {
|
||||
dir := withClientAPKDir(t)
|
||||
testResetClientAPKRateLimit()
|
||||
body := []byte("PK\x03\x04 fake apk bytes")
|
||||
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleClientAPK(rr, httptest.NewRequest(http.MethodGet, "/api/client/apk", nil))
|
||||
h.handleClientAPK(rr, authedRequest(http.MethodGet, "/api/client/apk", 0x02))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rr.Code)
|
||||
}
|
||||
@@ -113,3 +155,36 @@ func TestClientAPK_StreamsWithCorrectContentType(t *testing.T) {
|
||||
t.Errorf("body bytes mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPK_RateLimit_429OnRapidSecondCall(t *testing.T) {
|
||||
dir := withClientAPKDir(t)
|
||||
testResetClientAPKRateLimit()
|
||||
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), []byte("apk"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
|
||||
// First call from user 0x03 — succeeds.
|
||||
rr1 := httptest.NewRecorder()
|
||||
h.handleClientAPK(rr1, authedRequest(http.MethodGet, "/api/client/apk", 0x03))
|
||||
if rr1.Code != http.StatusOK {
|
||||
t.Fatalf("first call: want 200, got %d", rr1.Code)
|
||||
}
|
||||
|
||||
// Immediate second call from same user — rate-limited.
|
||||
rr2 := httptest.NewRecorder()
|
||||
h.handleClientAPK(rr2, authedRequest(http.MethodGet, "/api/client/apk", 0x03))
|
||||
if rr2.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second call: want 429, got %d", rr2.Code)
|
||||
}
|
||||
if got := rr2.Header().Get("Retry-After"); got == "" {
|
||||
t.Errorf("expected Retry-After header on 429 response")
|
||||
}
|
||||
|
||||
// Different user — not rate-limited.
|
||||
rr3 := httptest.NewRecorder()
|
||||
h.handleClientAPK(rr3, authedRequest(http.MethodGet, "/api/client/apk", 0x04))
|
||||
if rr3.Code != http.StatusOK {
|
||||
t.Errorf("different user: want 200, got %d", rr3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user