Skip to content

Fix custom rate limiters ignored due to mountPath mismatch#10893

Merged
tommoor merged 2 commits into
mainfrom
copilot/fix-custom-rate-limiter-issue
Dec 13, 2025
Merged

Fix custom rate limiters ignored due to mountPath mismatch#10893
tommoor merged 2 commits into
mainfrom
copilot/fix-custom-rate-limiter-issue

Conversation

Copilot AI commented Dec 13, 2025

Copy link
Copy Markdown
Contributor

Custom rate limiters are registered with fullPath (mountPath + path) but looked up with only ctx.path, causing 43+ routes across /api, /oauth, and plugins to fall back to default limits instead of their stricter custom limits.

Changes

  • defaultRateLimiter(): Construct fullPath identically to rateLimiter() before lookups
  • Updated 3 references from ctx.path to fullPath:
    • RateLimiter.hasRateLimiter()
    • RateLimiter.getRateLimiter()
    • Consume key construction
  • Updated metrics path tracking to use fullPath

Example

// Before: paths didn't match
rateLimiter() registers: "/api/documents.export" (mountPath + path)
defaultRateLimiter() lookups: "/documents.export" (path only)
 Custom limiter never found, falls back to default

// After: paths match
const fullPath = `${ctx.mountPath ?? ""}${ctx.path}`;
rateLimiter() registers: "/api/documents.export"
defaultRateLimiter() lookups: "/api/documents.export"
 Custom limiter correctly enforced

Impact

Routes like /api/documents.export (25 req/min), /api/teams.create (10 req/min), and other sensitive endpoints now enforce their intended strict limits instead of the permissive default.

Original prompt

This section details on the original issue you should resolve

<issue_title>[Detail Bug] Custom rate limiters ignored due to mountPath vs. path mismatch</issue_title>
<issue_description># Summary

  • Context: The rate limiter middleware system has two components: rateLimiter() which registers custom rate limits for specific routes, and defaultRateLimiter() which enforces those limits globally.
  • Bug: Custom rate limiters are registered using the full path (including mount path) but are looked up using only the context path, causing a path mismatch.
  • Actual vs. expected: Custom rate limiters are never enforced; instead, the default rate limiter is always used even when custom limits are defined.
  • Impact: All routes with custom rate limits (43+ routes across /api, /oauth, and plugin routes) are using incorrect rate limits, potentially allowing abuse on endpoints that should be more strictly limited.

Code with bug

// In defaultRateLimiter() - uses only ctx.path for lookup
export function defaultRateLimiter() {
  return async function rateLimiterMiddleware(ctx: Context, next: Next) {
    if (!env.RATE_LIMITER_ENABLED) {
      return next();
    }

    const key = RateLimiter.hasRateLimiter(ctx.path)  // <-- BUG 🔴 Uses only ctx.path
      ? `${ctx.path}:${ctx.ip}`
      : `${ctx.ip}`;
    const limiter = RateLimiter.getRateLimiter(ctx.path);  // <-- BUG 🔴 Uses only ctx.path

    try {
      await limiter.consume(key);
    } catch (rateLimiterRes) {
      // ... error handling
    }

    return next();
  };
}

// In rateLimiter() - registers using fullPath (mountPath + path)
export function rateLimiter(config: RateLimiterConfig) {
  return async function registerRateLimiterMiddleware(
    ctx: Context,
    next: Next
  ) {
    if (!env.RATE_LIMITER_ENABLED) {
      return next();
    }

    const fullPath = `${ctx.mountPath ?? ""}${ctx.path}`;  // <-- Constructs full path

    if (!RateLimiter.hasRateLimiter(fullPath)) {
      RateLimiter.setRateLimiter(
        fullPath,  // <-- Registers with full path including mount
        defaults(
          {
            ...config,
            points: config.requests,
          },
          {
            duration: 60,
            points: env.RATE_LIMITER_REQUESTS,
            keyPrefix: RateLimiter.RATE_LIMITER_REDIS_KEY_PREFIX,
            storeClient: Redis.defaultClient,
          }
        )
      );
    }

    return next();
  };
}

Evidence

Example

Consider a request to the /api/documents.export endpoint which has a custom rate limit of 25 requests per minute:

  1. The API router is mounted at /api in server/services/web.ts:

    app.use(mount("/api", api));
  2. When rateLimiter() middleware runs for the route:

    • ctx.mountPath = "/api"
    • ctx.path = "/documents.export"
    • fullPath = "/api/documents.export"
    • Registers rate limiter with key: "/api/documents.export"
  3. When defaultRateLimiter() middleware runs to enforce the limit:

    • Uses ctx.path = "/documents.export" for lookup
    • RateLimiter.hasRateLimiter("/documents.export") returns false
    • Falls back to RateLimiter.defaultRateLimiter instead of the custom limiter
    • Uses consume key: "127.0.0.1" (IP only, not path-specific)
  4. Result:

    • Expected: 25 requests per minute per IP per path
    • Actual: Default rate limit (typically much higher) per IP across all paths

Failing test

Test script

import { Context } from "koa";
import env from "@server/env";
import RateLimiter from "@server/utils/RateLimiter";
import { rateLimiter, defaultRateLimiter } from "./rateLimiter";

describe("rateLimiter middleware", () => {
  const originalRateLimiterEnabled = env.RATE_LIMITER_ENABLED;

  beforeEach(() => {
    // Enable rate limiter for tests
    env.RATE_LIMITER_ENABLED = true;
    // Clear the rate limiter map before each test
    RateLimiter.rateLimiterMap.clear();
  });

  afterEach(() => {
    // Restore original value
    env.RATE_LIMITER_ENABLED = originalRateLimiterEnabled;
  });

  it("should register and enforce custom rate limiter with matching paths", async () => {
    const customConfig = { duration: 60, requests: 5 };

    // Simulate the rateLimiter middleware registration
    const registerMiddleware = rateLimiter(customConfig);
    const mockCtx = {
      path: "/documents.export",
      mountPath: undefined, // No mount path
      ip: "127.0.0.1",
      set: jest.fn(),
      request: {},
    } as unknown as Context;

    await registerMiddleware(mockCtx, jest.fn());

    // Check if the rate limiter was registered
    const registeredPath = "/documents.export";
    expect(RateLimiter.hasRateLimiter(registeredPath)).toBe(true);

    // Simulate the defaultRateLimiter middleware lookup
    const limiter = RateLimiter.getRateLimiter(mockCtx.path);

    // Verify that the custom rate limiter is found
    expect(limiter).not.toBe(RateLimiter.default...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes outline/outline#10891

<!-- START COPILOT CODING AGENT TIPS -->
---

 Let Copilot coding agent [set things up for you](https://github.com/outline/outline/issues/new?title=+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI self-assigned this Dec 13, 2025
…miter

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Copilot AI changed the title [WIP] Fix custom rate limiters path mismatch bug Fix custom rate limiters ignored due to mountPath mismatch Dec 13, 2025
Copilot AI requested a review from tommoor December 13, 2025 16:36
@tommoor tommoor marked this pull request as ready for review December 13, 2025 16:41
@tommoor tommoor merged commit 478781a into main Dec 13, 2025
13 of 14 checks passed
@tommoor tommoor deleted the copilot/fix-custom-rate-limiter-issue branch December 13, 2025 17:14
veenone pushed a commit to veenone/outline that referenced this pull request Dec 17, 2025
…0893)

* Initial plan

* Fix rate limiter path mismatch bug by using fullPath in defaultRateLimiter

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
salihudickson pushed a commit to salihudickson/outline that referenced this pull request Dec 18, 2025
…0893)

* Initial plan

* Fix rate limiter path mismatch bug by using fullPath in defaultRateLimiter

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
alexlebens pushed a commit to alexlebens/infrastructure that referenced this pull request Jan 7, 2026
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [outlinewiki/outline](https://github.com/outline/outline) | minor | `1.1.0` → `1.2.0` |

---

### Release Notes

<details>
<summary>outline/outline (outlinewiki/outline)</summary>

### [`v1.2.0`](https://github.com/outline/outline/releases/tag/v1.2.0)

[Compare Source](outline/outline@v1.1.0...v1.2.0)

#### What's Changed

##### Highlights

**Diagrams.net** diagrams are now fully supported, insert new diagrams through the block menu or by uploading an existing png that was created in Diagrams.net – the original diagram data will be preserved and can be edited by clicking the "Edit" button in the image toolbar.

**Custom emoji** are now available – upload your own custom emoji in the admin settings and use them in your documents, comments, reactions, and icons.

**Improved revision history** with the ability to download any revision as HTML or Markdown, toggle whether changes are visible, and an improved rendering engine that retains more of the original document's formatting and structure.

**Authentication provider management** has been added to the settings, allowing admins to view and manage all configured authentication providers in one place. This includes the ability to disable providers, which will prevent users from signing in with that provider but will not delete any existing accounts.

**Passkey support** has been added as an optional login method. You can now sign in with biometric authentication (TouchId, Windows Hello) or security keys instead of a password. Existing workspaces will need to enable this on the authentication providers screen.

##### Other improvements

- The sidebar design was improved and refined in [#&#8203;10684](outline/outline#10684)
- It is now possible to upload and embed PDFs in [#&#8203;10198](outline/outline#10198)
- A "Popular" tab is now available for documents, popular docs are ranked higher in search in [#&#8203;10721](outline/outline#10721)
- A visual color palette is now available in the icon picker in [#&#8203;10696](outline/outline#10696)
- Avatar changes are now synced automatically from iDP in [#&#8203;10718](outline/outline#10718)
- User initials now supported in mention search in [#&#8203;10797](outline/outline#10797)
- New option to distribute table columns evenly in [#&#8203;10645](outline/outline#10645)
- Mermaid diagrams now have an explicit "Edit" option in the toolbar in [#&#8203;11060](outline/outline#11060)
- Added filtering to the notifications UI in [#&#8203;10916](outline/outline#10916)
- Added CSV export for member list in [#&#8203;10803](outline/outline#10803)
- Added CIDR range support to `ALLOWED_PRIVATE_IP_ADDRESSES` in [#&#8203;10923](outline/outline#10923)
- Add ContextMenu to RevisionListItem in [#&#8203;10952](outline/outline#10952)
- The GitHub integration now supports fetching details on public issues/PRs in [#&#8203;10827](outline/outline#10827)
- It is no longer required to use a public bucket for avatar images in [#&#8203;10977](outline/outline#10977)
- Implemented RFC 9700 hardening against refresh token reuse in [#&#8203;10960](outline/outline#10960)
- PKCE OAuth clients can now use refresh tokens in [#&#8203;10769](outline/outline#10769)
- Support for PostgreSQL multi-host connection URIs in `DATABASE_URL` in [#&#8203;10754](outline/outline#10754)
- Many internal performance improvements

##### Fixes

- Fixed display issues in share dialog in [#&#8203;10662](outline/outline#10662)
- Incompatibility between path and query search terms in [#&#8203;10667](outline/outline#10667)
- Restored ability to resize shared sidebar in [#&#8203;10669](outline/outline#10669)
- UI does not update when deleting API key in [#&#8203;10670](outline/outline#10670)
- Invalid access of `firstChild` for mermaid diagrams in [#&#8203;10668](outline/outline#10668)
- Plain text copy-to-clipboard serializer no longer squashes blocks in [#&#8203;10683](outline/outline#10683)
- When TOC extends beyond window bounds ensure headings scroll in [#&#8203;10687](outline/outline#10687)
- Added missing drop cursor in top position in [#&#8203;10689](outline/outline#10689)
- `Empty trash` button is now hidden when missing permissions in [#&#8203;10704](outline/outline#10704)
- Fixed search popover on public pages in [#&#8203;10717](outline/outline#10717)
- Multiple improvements to sitemap generation for public shares in [#&#8203;10716](outline/outline#10716)
- Fixed in-document find fails with multiple escaped characters in [#&#8203;10735](outline/outline#10735)
- Improved validation of urls extracted from data transfer event in [#&#8203;10740](outline/outline#10740)
- Middle-mouse button on internal link in Firefox no longer opens multiple tabs in [#&#8203;10748](outline/outline#10748)
- Fixed collection filter returning documents from all collections when no search query in [#&#8203;10775](outline/outline#10775)
- Templates are now inserted at cursor position instead of document start in [#&#8203;10783](outline/outline#10783)
- Shift paste with selection no longer inserts next to selection in [#&#8203;10799](outline/outline#10799)
- Fixed an issue where some Mermaid diagrams can't be expanded in [#&#8203;10807](outline/outline#10807)
- Collection overview now respects the separeat editing mode setting in [#&#8203;10816](outline/outline#10816)
- Query strings not forwarded on internal links from editor in [#&#8203;10854](outline/outline#10854)
- Shutdown during migrations does not release mutex lock in [#&#8203;10879](outline/outline#10879)
- `profileId` extraction in OIDC does not fallback to `token.sub` in [#&#8203;10882](outline/outline#10882)
- Fixed an issue where custom rate limiters were ignored due to mountPath mismatch in [#&#8203;10893](outline/outline#10893)
- Viewer role now replaced correctly on downgrade to guest in [#&#8203;10877](outline/outline#10877)
- Validation of `SECRET_KEY` environment variable tightened in [#&#8203;10897](outline/outline#10897)
- Fixed double pagination in `documents.list` and `documents.archived` with `sort=index` in [#&#8203;10895](outline/outline#10895)
- Comment actions now reliably appear in mobile drawer in [#&#8203;10904](outline/outline#10904)
- Fixed extra newlines in pasted code blocks in [#&#8203;10958](outline/outline#10958)
- Parser crash when pasting inline code containing checkboxes by [@&#8203;hdoo42](https://github.com/hdoo42) in [#&#8203;10949](outline/outline#10949)
- Fixed an issue where context menus could have context menus (menuception) in [#&#8203;10974](outline/outline#10974)
- Fixed invisible email buttons in iOS Mail dark mode in [#&#8203;10976](outline/outline#10976)
- Restored 'Create a doc' item in mention menu in [#&#8203;10980](outline/outline#10980)
- User with "can edit" permission on sub-document can now sort child documents in [#&#8203;10990](outline/outline#10990)
- Large base64 images pasted as HTML are now correctly handled in [#&#8203;10982](outline/outline#10982)
- Appending content via API no longer messes with existing document content in [#&#8203;10998](outline/outline#10998)
- Image warp exiting lightbox now correct in [#&#8203;10999](outline/outline#10999)
- Grips are now positioned correctly adjacent merged table cells in [#&#8203;11003](outline/outline#11003)
- Export no longer link to a non-accessible location for non-admins in [#&#8203;11070](outline/outline#11070)

#### New Contributors

- [@&#8203;nwleedev](https://github.com/nwleedev) made their first contribution in [#&#8203;10759](outline/outline#10759)
- [@&#8203;hdoo42](https://github.com/hdoo42) made their first contribution in [#&#8203;10949](outline/outline#10949)

**Full Changelog**: <outline/outline@v1.1.0...v1.2.0>

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi42OS4yIiwidXBkYXRlZEluVmVyIjoiNDIuNjkuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiaW1hZ2UiXX0=-->

Reviewed-on: https://gitea.alexlebens.dev/alexlebens/infrastructure/pulls/3075
Co-authored-by: Renovate Bot <renovate-bot@alexlebens.net>
Co-committed-by: Renovate Bot <renovate-bot@alexlebens.net>
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.

3 participants