diff --git a/appview/config.go b/appview/config.go index fcee261..6d7af1a 100644 --- a/appview/config.go +++ b/appview/config.go @@ -17,10 +17,12 @@ type Config struct { AuthReadTokens map[string]string AuthAdminTokens map[string]string CORSOrigins []string - RateLimitEnabled bool - RateLimitRPS float64 - RateLimitBurst int - AppleTeamID string + RateLimitEnabled bool + RateLimitRPS float64 + RateLimitBurst int + RateLimitStrictRPS float64 + RateLimitStrictBurst int + AppleTeamID string AppleBundleID string } @@ -54,6 +56,12 @@ func (c Config) Validate() error { if c.RateLimitEnabled && c.RateLimitBurst <= 0 { return fmt.Errorf("rate limit burst must be positive when rate limiting is enabled") } + if c.RateLimitEnabled && c.RateLimitStrictRPS <= 0 { + return fmt.Errorf("strict rate limit rps must be positive when rate limiting is enabled") + } + if c.RateLimitEnabled && c.RateLimitStrictBurst <= 0 { + return fmt.Errorf("strict rate limit burst must be positive when rate limiting is enabled") + } return nil } diff --git a/appview/config_test.go b/appview/config_test.go index 62d1182..a560ac8 100644 --- a/appview/config_test.go +++ b/appview/config_test.go @@ -58,8 +58,10 @@ func validConfig() Config { AuthReadTokens: map[string]string{"token": "did:plc:alice"}, AuthAdminTokens: map[string]string{}, CORSOrigins: []string{"https://app.effem.xyz"}, - RateLimitEnabled: true, - RateLimitRPS: 5, - RateLimitBurst: 20, + RateLimitEnabled: true, + RateLimitRPS: 5, + RateLimitBurst: 20, + RateLimitStrictRPS: 2, + RateLimitStrictBurst: 5, } } diff --git a/appview/httpmw/auth.go b/appview/httpmw/auth.go index d3dda6f..7d74a32 100644 --- a/appview/httpmw/auth.go +++ b/appview/httpmw/auth.go @@ -107,7 +107,8 @@ func Authentication(authorizer *TokenAuthorizer, required bool) echo.MiddlewareF if required { return writeAuthError(c, http.StatusUnauthorized, "AuthRequired", "missing bearer token or x-api-key") } - c.Set(principalContextKey, newPrincipal("*", scopeRead)) + // No token provided — proceed as anonymous (no principal). + // Route-level middleware (RequireScope) enforces auth on private endpoints. return next(c) } @@ -144,7 +145,7 @@ func RequireQueryDID(param string) echo.MiddlewareFunc { if !ok { return writeAuthError(c, http.StatusUnauthorized, "AuthRequired", "authentication required") } - if principal.IsAdmin() || principal.IsService() || principal.IsDevice() { + if principal.IsAdmin() || principal.IsService() { return next(c) } diff --git a/appview/httpmw/auth_test.go b/appview/httpmw/auth_test.go index 51c2d03..ca4ba48 100644 --- a/appview/httpmw/auth_test.go +++ b/appview/httpmw/auth_test.go @@ -94,7 +94,7 @@ func TestRequireQueryDID(t *testing.T) { } }) - t.Run("device bypass", func(t *testing.T) { + t.Run("device does not bypass DID check", func(t *testing.T) { deviceEcho := echo.New() deviceAuthorizer := NewTokenAuthorizer( map[string]string{"device-token": "device:1"}, @@ -111,8 +111,69 @@ func TestRequireQueryDID(t *testing.T) { req.Header.Set(echo.HeaderAuthorization, "Bearer device-token") rec := httptest.NewRecorder() deviceEcho.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rec.Code) + } + }) +} + +func TestAnonymousPublicRoute(t *testing.T) { + e := echo.New() + authorizer := NewTokenAuthorizer(nil, nil, nil) + + e.Use(Authentication(authorizer, false)) + + // Public route — no RequireScope. + e.GET("/xrpc/public", func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }) + + t.Run("anonymous succeeds", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/xrpc/public", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } }) + + t.Run("authenticated also succeeds", func(t *testing.T) { + authE := echo.New() + authAuthorizer := NewTokenAuthorizer( + map[string]string{"read-token": "did:plc:alice"}, + nil, + nil, + ) + authE.Use(Authentication(authAuthorizer, false)) + authE.GET("/xrpc/public", func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/public", nil) + req.Header.Set(echo.HeaderAuthorization, "Bearer read-token") + rec := httptest.NewRecorder() + authE.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + }) +} + +func TestAnonymousPrivateRoute(t *testing.T) { + e := echo.New() + authorizer := NewTokenAuthorizer(nil, nil, nil) + + e.Use(Authentication(authorizer, false)) + + // Private route — RequireScope enforces auth. + e.GET("/xrpc/private", func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }, RequireScope("read")) + + req := httptest.NewRequest(http.MethodGet, "/xrpc/private", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } } diff --git a/appview/server.go b/appview/server.go index a94334b..92c7ae7 100644 --- a/appview/server.go +++ b/appview/server.go @@ -20,13 +20,14 @@ import ( ) type Server struct { - db *gorm.DB - echo *echo.Echo - pi *podcastindex.CachedClient - indexer *indexer.Indexer - config Config - logger *slog.Logger - lastSeq int64 + db *gorm.DB + echo *echo.Echo + pi *podcastindex.CachedClient + indexer *indexer.Indexer + config Config + logger *slog.Logger + lastSeq int64 + strictLimiter *httpmw.PrincipalRateLimiter } func NewServer(cfg Config) (*Server, error) { @@ -58,6 +59,17 @@ func NewServer(cfg Config) (*Server, error) { return nil, fmt.Errorf("invalid rate limiter configuration: %w", err) } + // Stricter rate limiter for expensive endpoints (search, inbox, recommendations). + strictLimiter, err := httpmw.NewPrincipalRateLimiter(httpmw.RateLimiterConfig{ + Enabled: cfg.RateLimitEnabled, + RPS: cfg.RateLimitStrictRPS, + Burst: cfg.RateLimitStrictBurst, + BucketTTL: 5 * time.Minute, + }) + if err != nil { + return nil, fmt.Errorf("invalid strict rate limiter configuration: %w", err) + } + e := echo.New() e.HideBanner = true e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ @@ -75,16 +87,20 @@ func NewServer(cfg Config) (*Server, error) { return nil }, })) - e.Use(httpmw.Authentication(authz, cfg.AuthRequired)) + // Auth is permissive globally: try to authenticate if a token is present, + // but allow anonymous requests through. Route-level middleware (RequireScope) + // enforces auth on private endpoints. + e.Use(httpmw.Authentication(authz, false)) e.Use(rateLimiter.Middleware) srv := &Server{ - db: db, - echo: e, - pi: cachedPI, - indexer: indexer.New(db, logger), - config: cfg, - logger: logger, + db: db, + echo: e, + pi: cachedPI, + indexer: indexer.New(db, logger), + config: cfg, + logger: logger, + strictLimiter: strictLimiter, } srv.registerRoutes() @@ -98,43 +114,41 @@ func (srv *Server) registerRoutes() { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) }) - // Device attestation endpoints (no auth required — outside /xrpc/). + // Device attestation endpoints — optional enrollment, not required for browsing. srv.echo.POST("/v1/device/challenge", h.DeviceChallenge) srv.echo.POST("/v1/device/attest", h.DeviceAttest) - xrpc := srv.echo.Group("/xrpc", httpmw.RequireScope("read")) - - xrpc.GET("/xyz.effem.feed.getSubscriptions", h.GetSubscriptions, httpmw.RequireQueryDID("did")) - xrpc.GET("/xyz.effem.feed.getSubscribers", h.GetSubscribers) - - xrpc.GET("/xyz.effem.feed.getComments", h.GetComments) - xrpc.GET("/xyz.effem.feed.getCommentThread", h.GetCommentThread) - - xrpc.GET("/xyz.effem.feed.getRecommendations", h.GetRecommendations) - xrpc.GET("/xyz.effem.feed.getPopular", h.GetPopular) - - xrpc.GET("/xyz.effem.feed.getList", h.GetList) - xrpc.GET("/xyz.effem.feed.getLists", h.GetLists, httpmw.RequireQueryDID("did")) - - xrpc.GET("/xyz.effem.feed.getBookmarks", h.GetBookmarks, httpmw.RequireQueryDID("did")) - xrpc.GET("/xyz.effem.feed.getEpisodeStates", h.GetEpisodeStates, httpmw.RequireQueryDID("did")) - - xrpc.GET("/xyz.effem.feed.getBlocks", h.GetBlocks, httpmw.RequireQueryDID("did")) - - xrpc.GET("/xyz.effem.actor.getProfile", h.GetProfile) - - xrpc.GET("/xyz.effem.feed.getInbox", h.GetInbox, httpmw.RequireQueryDID("did")) - - xrpc.GET("/xyz.effem.search.podcasts", h.SearchPodcasts) - xrpc.GET("/xyz.effem.search.episodes", h.SearchEpisodes) - - xrpc.GET("/xyz.effem.podcast.getPodcast", h.GetPodcast) - xrpc.GET("/xyz.effem.podcast.getEpisodes", h.GetEpisodes) - xrpc.GET("/xyz.effem.podcast.getEpisode", h.GetEpisode) - xrpc.GET("/xyz.effem.podcast.getTrending", h.GetTrending) - xrpc.GET("/xyz.effem.podcast.getCategories", h.GetCategories) - xrpc.GET("/xyz.effem.podcast.getRecentEpisodes", h.GetRecentEpisodes) - xrpc.GET("/xyz.effem.podcast.getStats", h.GetStats) + // Public AppView reads — no auth required, anonymous access allowed. + pub := srv.echo.Group("/xrpc") + + pub.GET("/xyz.effem.feed.getSubscriptions", h.GetSubscriptions) + pub.GET("/xyz.effem.feed.getSubscribers", h.GetSubscribers) + pub.GET("/xyz.effem.feed.getComments", h.GetComments) + pub.GET("/xyz.effem.feed.getCommentThread", h.GetCommentThread) + pub.GET("/xyz.effem.feed.getRecommendations", h.GetRecommendations, srv.strictLimiter.Middleware) + pub.GET("/xyz.effem.feed.getPopular", h.GetPopular, srv.strictLimiter.Middleware) + pub.GET("/xyz.effem.feed.getList", h.GetList) + pub.GET("/xyz.effem.feed.getLists", h.GetLists) + pub.GET("/xyz.effem.actor.getProfile", h.GetProfile) + + pub.GET("/xyz.effem.search.podcasts", h.SearchPodcasts, srv.strictLimiter.Middleware) + pub.GET("/xyz.effem.search.episodes", h.SearchEpisodes, srv.strictLimiter.Middleware) + + pub.GET("/xyz.effem.podcast.getPodcast", h.GetPodcast) + pub.GET("/xyz.effem.podcast.getEpisodes", h.GetEpisodes) + pub.GET("/xyz.effem.podcast.getEpisode", h.GetEpisode) + pub.GET("/xyz.effem.podcast.getTrending", h.GetTrending) + pub.GET("/xyz.effem.podcast.getCategories", h.GetCategories) + pub.GET("/xyz.effem.podcast.getRecentEpisodes", h.GetRecentEpisodes) + pub.GET("/xyz.effem.podcast.getStats", h.GetStats) + + // Private user-owned reads — require auth + DID ownership. + priv := srv.echo.Group("/xrpc", httpmw.RequireScope("read")) + + priv.GET("/xyz.effem.feed.getBookmarks", h.GetBookmarks, httpmw.RequireQueryDID("did")) + priv.GET("/xyz.effem.feed.getEpisodeStates", h.GetEpisodeStates, httpmw.RequireQueryDID("did")) + priv.GET("/xyz.effem.feed.getBlocks", h.GetBlocks, httpmw.RequireQueryDID("did")) + priv.GET("/xyz.effem.feed.getInbox", h.GetInbox, httpmw.RequireQueryDID("did"), srv.strictLimiter.Middleware) } func (srv *Server) RunAPI(ctx context.Context) error { diff --git a/cmd/effem-appview/main.go b/cmd/effem-appview/main.go index f70166a..3755ed5 100644 --- a/cmd/effem-appview/main.go +++ b/cmd/effem-appview/main.go @@ -97,6 +97,18 @@ func main() { EnvVars: []string{"EFFEM_RATE_LIMIT_BURST"}, Usage: "Per-principal burst request capacity", }, + &cli.Float64Flag{ + Name: "rate-limit-strict-rps", + Value: 2, + EnvVars: []string{"EFFEM_RATE_LIMIT_STRICT_RPS"}, + Usage: "Per-principal request rate for expensive endpoints (search, inbox)", + }, + &cli.IntFlag{ + Name: "rate-limit-strict-burst", + Value: 5, + EnvVars: []string{"EFFEM_RATE_LIMIT_STRICT_BURST"}, + Usage: "Per-principal burst capacity for expensive endpoints", + }, &cli.StringFlag{ Name: "apple-team-id", Value: "VFXJQ4U7H8", @@ -144,9 +156,11 @@ func run(cctx *cli.Context) error { AuthReadTokens: readTokens, AuthAdminTokens: adminTokens, CORSOrigins: appview.ParseCommaList(cctx.String("cors-allowed-origins")), - RateLimitEnabled: cctx.Bool("rate-limit-enabled"), - RateLimitRPS: cctx.Float64("rate-limit-rps"), - RateLimitBurst: cctx.Int("rate-limit-burst"), + RateLimitEnabled: cctx.Bool("rate-limit-enabled"), + RateLimitRPS: cctx.Float64("rate-limit-rps"), + RateLimitBurst: cctx.Int("rate-limit-burst"), + RateLimitStrictRPS: cctx.Float64("rate-limit-strict-rps"), + RateLimitStrictBurst: cctx.Int("rate-limit-strict-burst"), AppleTeamID: cctx.String("apple-team-id"), AppleBundleID: cctx.String("apple-bundle-id"), }