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:
@@ -5,25 +5,42 @@ package api
|
||||
// app store. CI sequencing bakes the APK + sidecar version file into
|
||||
// /app/client/ at image build time.
|
||||
//
|
||||
// Both endpoints are unauthenticated — install flow shouldn't depend
|
||||
// on a live session, and the URL is hard to guess at scale.
|
||||
// Both endpoints are authenticated — the bandwidth cost of the APK
|
||||
// (~30-60 MB) makes anonymous access an abuse vector. The Flutter
|
||||
// client's polling only fires after login (banner mounts in the post-
|
||||
// login shell), so this gate is invisible to the actual update flow.
|
||||
//
|
||||
// /api/client/apk additionally rate-limits per user to a single
|
||||
// download every 60s. Real install flows fire one download per
|
||||
// update; anything tighter is scripted/abusive.
|
||||
//
|
||||
// Returns 404 gracefully when the APK isn't present (dev environments,
|
||||
// pre-CI-wiring); the Flutter client treats 404 as "no update channel
|
||||
// available" and falls back to the manual download.
|
||||
// available."
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientAPKDir = "/app/client"
|
||||
clientAPKFilename = "minstrel.apk"
|
||||
clientVersionFile = "minstrel.apk.version"
|
||||
|
||||
// clientAPKMinInterval throttles per-user APK downloads. 60s is
|
||||
// generous for a real install flow (one download) and tight enough
|
||||
// to suppress accidental hammering or scripted abuse.
|
||||
clientAPKMinInterval = 60 * time.Second
|
||||
)
|
||||
|
||||
// clientAPKDir resolves the directory holding the bundled APK. Env
|
||||
@@ -36,6 +53,37 @@ func clientAPKDir() string {
|
||||
return defaultClientAPKDir
|
||||
}
|
||||
|
||||
// clientAPKLastDownload tracks the last APK-download timestamp per
|
||||
// user id (UUID hex string). Cheap in-memory map under mutex; a single
|
||||
// household has at most a handful of users so the map never grows.
|
||||
// Reset on process restart, which is fine — abuse protection, not
|
||||
// audit. testResetClientAPKRateLimit() lets tests start clean.
|
||||
var (
|
||||
clientAPKLastDownload = map[string]time.Time{}
|
||||
clientAPKLastDownloadMu sync.Mutex
|
||||
)
|
||||
|
||||
func testResetClientAPKRateLimit() {
|
||||
clientAPKLastDownloadMu.Lock()
|
||||
defer clientAPKLastDownloadMu.Unlock()
|
||||
clientAPKLastDownload = map[string]time.Time{}
|
||||
}
|
||||
|
||||
// clientAPKAllowDownload checks + updates the per-user rate-limit
|
||||
// state. Returns the wait duration if blocked, or 0 if allowed.
|
||||
func clientAPKAllowDownload(userID string, now time.Time) time.Duration {
|
||||
clientAPKLastDownloadMu.Lock()
|
||||
defer clientAPKLastDownloadMu.Unlock()
|
||||
if last, ok := clientAPKLastDownload[userID]; ok {
|
||||
elapsed := now.Sub(last)
|
||||
if elapsed < clientAPKMinInterval {
|
||||
return clientAPKMinInterval - elapsed
|
||||
}
|
||||
}
|
||||
clientAPKLastDownload[userID] = now
|
||||
return 0
|
||||
}
|
||||
|
||||
type clientVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
APKURL string `json:"apk_url"`
|
||||
@@ -77,8 +125,21 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
// handleClientAPK streams the bundled APK with the correct
|
||||
// Content-Type so Android's PackageInstaller accepts it.
|
||||
// Content-Type so Android's PackageInstaller accepts it. Per-user
|
||||
// rate-limited (clientAPKMinInterval).
|
||||
func (h *handlers) handleClientAPK(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":{"code":"unauthenticated","message":"login required"}}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID := syncpkg.FormatUUID(user.ID)
|
||||
if wait := clientAPKAllowDownload(userID, time.Now()); wait > 0 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
|
||||
http.Error(w, `{"error":{"code":"rate_limited","message":"too many downloads; try again shortly"}}`, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
apkPath := filepath.Join(clientAPKDir(), clientAPKFilename)
|
||||
f, err := os.Open(apkPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
Reference in New Issue
Block a user