Skip to content

feat(security): rate-limit public share endpoint + security headers#52

Merged
DavidsonGomes merged 1 commit into
developfrom
host/rate-limit
Apr 25, 2026
Merged

feat(security): rate-limit public share endpoint + security headers#52
DavidsonGomes merged 1 commit into
developfrom
host/rate-limit

Conversation

@DavidsonGomes
Copy link
Copy Markdown
Member

@DavidsonGomes DavidsonGomes commented Apr 25, 2026

Summary

  • Vault §2.S1 CRITICAL: add flask-limiter (in-memory, single-process) — 60 req/min/IP on /api/shares/<token>/view
  • Vault §2.S2: inject Referrer-Policy: no-referrer, Cache-Control: no-store, Pragma: no-cache, HSTS, X-Content-Type-Options on every public share response
  • Add flask-limiter>=3.5 to pyproject.toml dependencies
  • Create dashboard/backend/rate_limit.py singleton (breaks circular-import between app.py → blueprints)
  • Global default 600 req/min on all routes; sensitive endpoints override with @limiter.limit()

Test plan

  • 71 existing backend tests pass (excluding pre-existing test_cache_hit_does_not_call_compute_again failure unrelated to this PR)
  • Manual: 61st request in 1 min to /api/shares/<token>/view → HTTP 429 + Retry-After header
  • Manual: share response includes all 5 security headers

🤖 Generated with Claude Code

Summary by Sourcery

Introduce request rate limiting and stricter security headers for public share endpoints.

New Features:

  • Add rate limiting to the public file share view endpoint to cap requests per IP address.
  • Introduce a shared Flask-Limiter instance with a default global rate limit for all routes.

Enhancements:

  • Apply standard security headers to all public share responses to improve caching, referrer, content-type, and transport security.
  • Add flask-limiter as a new backend dependency.

Vault audit §2.S1 CRITICAL: /api/shares/<token>/view had zero rate
limiting. Add flask-limiter (in-memory, single-process MVP) with:
- 60 req/min/IP on view_share (Vault §2.S1)
- Global default 600 req/min on all other routes (non-blocking baseline)
- Referrer-Policy, Cache-Control no-store, Pragma, HSTS, X-Content-Type-Options
  headers on every public share response (Vault §2.S2)

The Limiter singleton lives in rate_limit.py to break the circular-import
chain between app.py (which imports route blueprints) and the blueprints
that need @limiter.limit() decorators.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 25, 2026

Reviewer's Guide

Adds Flask-Limiter-based rate limiting and security headers to the public share view endpoint, introduces a shared limiter singleton, and wires the new dependency into the project configuration.

Sequence diagram for rate-limited public share view endpoint

sequenceDiagram
    actor Client
    participant FlaskApp
    participant Limiter
    participant SharesBlueprint
    participant AfterThisRequestHandler

    Client->>FlaskApp: GET /api/shares/<token>/view
    FlaskApp->>Limiter: check_rate_limit(remote_address, 60 per minute)
    alt over_limit
        Limiter-->>FlaskApp: limit_exceeded
        FlaskApp-->>Client: 429 Too Many Requests + Retry-After
    else within_limit
        Limiter-->>FlaskApp: allowed
        FlaskApp->>SharesBlueprint: call view_share(token)
        SharesBlueprint->>AfterThisRequestHandler: register _add_security_headers
        SharesBlueprint-->>FlaskApp: Response(file_content)
        FlaskApp->>AfterThisRequestHandler: apply _add_security_headers(Response)
        AfterThisRequestHandler-->>FlaskApp: Response with security headers
        FlaskApp-->>Client: 200 OK + file + security headers
    end
Loading

Class diagram for limiter singleton and shares endpoint integration

classDiagram
    class Limiter {
        +list default_limits
        +string storage_uri
        +Limiter callable key_func
        +init_app(app)
        +limit(limit_string)
    }

    class rate_limit {
        +Limiter limiter
    }

    class SharesBlueprint {
        +view_share(token)
    }

    rate_limit --> Limiter : creates_instance_of
    SharesBlueprint --> rate_limit : imports_limiter
    SharesBlueprint ..> Limiter : uses_limit_decorator_on_view_share
Loading

File-Level Changes

Change Details Files
Introduce rate limiting and security headers for the unauthenticated public share view endpoint.
  • Import after_this_request and shared limiter into the shares blueprint module
  • Apply a 60-requests-per-minute limit decorator to the /api/shares//view route
  • Register an after_this_request handler inside view_share to inject Referrer-Policy, Cache-Control, Pragma, X-Content-Type-Options, and Strict-Transport-Security headers on every response from the endpoint
dashboard/backend/routes/shares.py
Add flask-limiter as a backend dependency.
  • Declare flask-limiter>=3.5 in the project dependencies list so it is installed with the backend
pyproject.toml
Create a shared, uninitialized Flask-Limiter singleton with global defaults for use across blueprints.
  • Define a Limiter instance configured with get_remote_address, a default global rate of 600 requests per minute, and in-memory storage
  • Document that app.py is responsible for calling limiter.init_app(app) and that blueprints should import limiter from this module to avoid circular imports
dashboard/backend/rate_limit.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • The @after_this_request handler is defined after several early return paths in view_share, so 404/410 and other error responses from this endpoint will not receive the new security headers; consider registering the handler immediately after entering the view to cover all responses.
  • The import from rate_limit import limiter may be fragile depending on how the backend package is laid out; using a relative import such as from .rate_limit import limiter can avoid issues when the app is imported as a package.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `@after_this_request` handler is defined after several early `return` paths in `view_share`, so 404/410 and other error responses from this endpoint will not receive the new security headers; consider registering the handler immediately after entering the view to cover all responses.
- The import `from rate_limit import limiter` may be fragile depending on how the backend package is laid out; using a relative import such as `from .rate_limit import limiter` can avoid issues when the app is imported as a package.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@DavidsonGomes DavidsonGomes merged commit e50aefd into develop Apr 25, 2026
4 checks passed
@DavidsonGomes DavidsonGomes deleted the host/rate-limit branch April 25, 2026 18:33
DavidsonGomes added a commit that referenced this pull request Apr 25, 2026
…ortals (#53)

* feat(security): rate-limit public share endpoint + security headers

Vault audit §2.S1 CRITICAL: /api/shares/<token>/view had zero rate
limiting. Add flask-limiter (in-memory, single-process MVP) with:
- 60 req/min/IP on view_share (Vault §2.S1)
- Global default 600 req/min on all other routes (non-blocking baseline)
- Referrer-Policy, Cache-Control no-store, Pragma, HSTS, X-Content-Type-Options
  headers on every public share response (Vault §2.S2)

The Limiter singleton lives in rate_limit.py to break the circular-import
chain between app.py (which imports route blueprints) and the blueprints
that need @limiter.limit() decorators.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(plugins): B2.0 public_pages capability — read-only token-bound portals

Add the public_pages plugin capability (B2.0 scope, read-only):

plugin_schema.py:
- Add Capability.public_pages and Capability.safe_uninstall enum values
- Add PluginPublicPageTokenSource + PluginPublicPage Pydantic models
  (bundle must be under ui/public/, revoked_when disallowed in v1 to prevent SQL injection)
- Extend ReadonlyQuery with public_via + bind_token_param fields
- Add 4 PluginManifest model validators: capability required, table slug-prefix,
  unique ids/route_prefixes, readonly_data references valid page

routes/plugin_public_pages.py (new):
- GET /p/<slug>/<route_prefix>/<token>       — serve portal bundle (60 req/min/IP)
- GET /p/<slug>/<route_prefix>/<token>/data  — serve token-bound readonly query (120/min)
- GET /p/<slug>/public-assets/<path>         — serve ui/public/ static assets
- Token validation via parametric SQL (identifiers validated at install by schema)
- Module-level _PLUGIN_PUBLIC_PREFIXES cache for install/uninstall lifecycle
- Vault §B2.S2: Referrer-Policy, Cache-Control no-store, HSTS, X-Content-Type-Options on all responses
- CSP: default-src 'self' on portal bundles
- Rate limiting via rate_limit.limiter (imported from PR #52)

app.py:
- Import and register plugin_public_pages_bp
- /p/... paths already bypass auth_middleware (non-/api/ paths are passthrough)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
DavidsonGomes added a commit that referenced this pull request Apr 26, 2026
)

Bumps version to 0.33.0 so the plugin nutri (which requires this version)
can install. Bundles the five plugin-contract PRs (#52#56) merged today
into a single release. Plus a UX fix on the install wizard so 409s say why
they conflicted.

The fix
- lib/api.ts buildError now falls back to data.conflicts[0] when the
  standard error/message fields are absent. The plugin preview endpoint
  returns {conflicts: string[], manifest, ...} on 409 — without this fix
  the wizard showed only "409 CONFLICT" with the actual reason hidden.
- PluginInstallModal: conflicts type was Record<string, unknown>, backend
  always returned string[]; the JSON.keys() coercion produced index strings.
  Now typed as string[] and rendered as a list.

Tested
- Frontend tsc --noEmit clean
- Plugin nutri 200 pytest still pass after the 11 `# nosec B603` markers
  added to subprocess.run calls (false positives from regex security scan —
  all calls use list args, no shell=True)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jbmendonca added a commit to jbmendonca/evo-nexus that referenced this pull request Apr 27, 2026
v0.33.0 — plugin contract bundle (PRs evolution-foundation#52evolution-foundation#56) + install error surfacing
jbmendonca pushed a commit to jbmendonca/evo-nexus that referenced this pull request May 15, 2026
…volution-foundation#52)

Vault audit §2.S1 CRITICAL: /api/shares/<token>/view had zero rate
limiting. Add flask-limiter (in-memory, single-process MVP) with:
- 60 req/min/IP on view_share (Vault §2.S1)
- Global default 600 req/min on all other routes (non-blocking baseline)
- Referrer-Policy, Cache-Control no-store, Pragma, HSTS, X-Content-Type-Options
  headers on every public share response (Vault §2.S2)

The Limiter singleton lives in rate_limit.py to break the circular-import
chain between app.py (which imports route blueprints) and the blueprints
that need @limiter.limit() decorators.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
jbmendonca pushed a commit to jbmendonca/evo-nexus that referenced this pull request May 15, 2026
…ortals (evolution-foundation#53)

* feat(security): rate-limit public share endpoint + security headers

Vault audit §2.S1 CRITICAL: /api/shares/<token>/view had zero rate
limiting. Add flask-limiter (in-memory, single-process MVP) with:
- 60 req/min/IP on view_share (Vault §2.S1)
- Global default 600 req/min on all other routes (non-blocking baseline)
- Referrer-Policy, Cache-Control no-store, Pragma, HSTS, X-Content-Type-Options
  headers on every public share response (Vault §2.S2)

The Limiter singleton lives in rate_limit.py to break the circular-import
chain between app.py (which imports route blueprints) and the blueprints
that need @limiter.limit() decorators.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(plugins): B2.0 public_pages capability — read-only token-bound portals

Add the public_pages plugin capability (B2.0 scope, read-only):

plugin_schema.py:
- Add Capability.public_pages and Capability.safe_uninstall enum values
- Add PluginPublicPageTokenSource + PluginPublicPage Pydantic models
  (bundle must be under ui/public/, revoked_when disallowed in v1 to prevent SQL injection)
- Extend ReadonlyQuery with public_via + bind_token_param fields
- Add 4 PluginManifest model validators: capability required, table slug-prefix,
  unique ids/route_prefixes, readonly_data references valid page

routes/plugin_public_pages.py (new):
- GET /p/<slug>/<route_prefix>/<token>       — serve portal bundle (60 req/min/IP)
- GET /p/<slug>/<route_prefix>/<token>/data  — serve token-bound readonly query (120/min)
- GET /p/<slug>/public-assets/<path>         — serve ui/public/ static assets
- Token validation via parametric SQL (identifiers validated at install by schema)
- Module-level _PLUGIN_PUBLIC_PREFIXES cache for install/uninstall lifecycle
- Vault §B2.S2: Referrer-Policy, Cache-Control no-store, HSTS, X-Content-Type-Options on all responses
- CSP: default-src 'self' on portal bundles
- Rate limiting via rate_limit.limiter (imported from PR evolution-foundation#52)

app.py:
- Import and register plugin_public_pages_bp
- /p/... paths already bypass auth_middleware (non-/api/ paths are passthrough)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
jbmendonca pushed a commit to jbmendonca/evo-nexus that referenced this pull request May 15, 2026
…volution-foundation#57)

Bumps version to 0.33.0 so the plugin nutri (which requires this version)
can install. Bundles the five plugin-contract PRs (evolution-foundation#52evolution-foundation#56) merged today
into a single release. Plus a UX fix on the install wizard so 409s say why
they conflicted.

The fix
- lib/api.ts buildError now falls back to data.conflicts[0] when the
  standard error/message fields are absent. The plugin preview endpoint
  returns {conflicts: string[], manifest, ...} on 409 — without this fix
  the wizard showed only "409 CONFLICT" with the actual reason hidden.
- PluginInstallModal: conflicts type was Record<string, unknown>, backend
  always returned string[]; the JSON.keys() coercion produced index strings.
  Now typed as string[] and rendered as a list.

Tested
- Frontend tsc --noEmit clean
- Plugin nutri 200 pytest still pass after the 11 `# nosec B603` markers
  added to subprocess.run calls (false positives from regex security scan —
  all calls use list args, no shell=True)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant