Skip to content

Conversation

@jakehwll
Copy link
Contributor

Closes #20857

This PR fixes rate limiting by user authentication. Previously, we weren't passing apiKeyMiddlewareOptional to the rate limit middleware, which meant the apiKey was never available in the rate limiter:

// Prioritize by user, but fallback to IP.
apiKey, ok := r.Context().Value(apiKeyContextKey{}).(database.APIKey)
if !ok {
    return httprate.KeyByIP(r)
}

Testing

Note

I've prepended (User) to the rate limit error messages in RateLimitByAuthToken() to distinguish them from the generic RateLimit() function (which shares the same error message, but to catch the error we use limit+1 on RateLimit() whilst debugging). This ensures we're actually testing the user-based rate limiting.

Without bypass header (rate limited after 5 requests)

# Set rate limit to 5 requests per minute
export CODER_API_RATE_LIMIT=5

# Make 10 requests - should be limited after the 5th
for i in {1..10}; do 
  curl --request GET \
    --url http://localhost:8080/api/v2/regions \
    --header 'Cookie: dev_coder_session_token=...'
done
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"message":"(User) You've been rate limited for sending more than 5 requests in 1m0s."}
{"message":"(User) You've been rate limited for sending more than 5 requests in 1m0s."}
{"message":"(User) You've been rate limited for sending more than 5 requests in 1m0s."}
{"message":"(User) You've been rate limited for sending more than 5 requests in 1m0s."}
{"message":"(User) You've been rate limited for sending more than 5 requests in 1m0s."}

Result: First 5 requests succeed, remaining 5 are rate limited with error message: "(User) You've been rate limited for sending more than 5 requests in 1m0s."

With bypass header (no rate limiting)

# Same setup with bypass header
for i in {1..10}; do 
  curl --request GET \
    --url http://localhost:8080/api/v2/regions \
    --header 'Cookie: dev_coder_session_token=...' \
    --header 'X-Coder-Bypass-Ratelimit: true'
done
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}
{"regions":[{"id":"...","name":"primary","display_name":"Default","icon_url":"/emojis/1f3e1.png","healthy":true,"path_app_url":"http://127.0.0.1:3000","wildcard_hostname":""}]}

Result: All 10 requests succeed without rate limiting.

Copy link
Member

@ethanndickson ethanndickson left a comment

Choose a reason for hiding this comment

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

I know it's a draft, but we definitely should add a regression test for this

Comment on lines +482 to +496
// Test that /api/experimental works with authentication
t.Run("APIExperimentalWithAuth", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitLong)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/experimental", nil)
require.NoError(t, err)
req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())

resp, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

require.Equal(t, http.StatusOK, resp.StatusCode, "should succeed with auth")
})
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wanted to add a regression test for Experimental routes other than / but I don't think there is anything that will be long-lived here (being that we either promote or remove).

Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense, / is the only thing that won't get promoted eventually.

@jakehwll jakehwll marked this pull request as ready for review December 23, 2025 02:56
apiRateLimiter,
httpmw.ReportCLITelemetry(api.Logger, options.Telemetry),
)
r.Get("/", apiRoot)
Copy link
Contributor Author

@jakehwll jakehwll Dec 23, 2025

Choose a reason for hiding this comment

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

Added an apiRoot here so we can test against something long-lived. Same as the root api/v2/ handler

$ curl http://localhost:8080/api/experimental
{"message":"👋"}

Copy link
Contributor

@spikecurtis spikecurtis left a comment

Choose a reason for hiding this comment

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

There are 2 other instances of the rate limit handler being used, currently lines 892 and 915 -- these are for workspace app routes and won't have the user identity available.


r.NotFound(func(rw http.ResponseWriter, _ *http.Request) { httpapi.RouteNotFound(rw) })
r.Use(
apiKeyMiddlewareOptional,
Copy link
Contributor

Choose a reason for hiding this comment

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

This needs a more significant refactor than just adding apiKeyMiddlewareOptional at the top of the stack of middlewares.

The problem is that when we add apiKeyMiddleware deeper in the route tree, that middleware restarts the whole process of extracting the API key, including making another database call.

Instead we need an architecture where we always attempt to extract the API key at the top of the route tree, and then deeper in the tree, when we want to make it required, we just check that the identity has been added to the context.

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.

bug: rate limit by user is broken

4 participants