-
Notifications
You must be signed in to change notification settings - Fork 285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: RedwoodJS Middleware to rate limit requests #1606
base: redwood
Are you sure you want to change the base?
Changes from 1 commit
e7cb060
42e3169
b3d68f9
da515f5
1b6e655
6e35a37
e71d942
07d96a3
495e268
580db94
3be7995
88af99c
59aa8b5
a1276ba
0f5448a
58a25cf
b7a7a96
5e49141
15f4040
5e6d696
4b9cf39
2a0afd5
2b5b316
ad04dd7
884db75
d70379c
7ca2759
4781fe4
3e23eaf
670ce90
01d2e6f
0689f0c
550c271
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
/// <reference types="next" /> | ||
/// <reference types="next/image-types/global" /> | ||
/// <reference types="next/navigation-types/compat/navigation" /> | ||
|
||
// NOTE: This file should not be edited | ||
// see https://nextjs.org/docs/basic-features/typescript for more information. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1 @@ | ||
import { Unkey } from "@unkey/api"; | ||
|
||
import { version } from "../package.json"; | ||
|
||
export async function doTheMagicHere() { | ||
const _unkey = new Unkey({ | ||
rootKey: "GET_THIS_FROM_ENV_SOMEHOW", | ||
wrapperSdkVersion: `@unkey/redwoodjs@${version}`, | ||
disableTelemetry: false, // TODO: andreas | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @chronark I noticed that the rate limit sdk didn't have an option to add the version like the main Unkey api did. Do you still want to include the telemetry option as well? |
||
}); | ||
|
||
// do stuff :) | ||
} | ||
export * from "./ratelimit"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
# withUnkey | ||
|
||
RedwoodJS Middleware to rate limit requests | ||
|
||
## Setup | ||
|
||
See [Rate limiting Onboarding](https://www.unkey.com/docs/onboarding/onboarding-ratelimiting) to get started with standalone [rate limiting](https://www.unkey.com/docs/apis/features/ratelimiting) from [Unkey](https://www.unkey.com). | ||
|
||
Note: Be sure to set your `UNKEY_ROOT_KEY` or key to be used for rate limiting in an `.env` file. | ||
|
||
## Examples | ||
|
||
### With Third Party Authentication like Supabase | ||
|
||
Here, we use a custom identifier function `supabaseRatelimitIdentifier` that: | ||
|
||
- checks is the request is authenticated | ||
- constructs the identifier `sub` from the current user, since here the currentUser will be a JWT where the user id is the `sub` claim | ||
- registers `supabaseAuthMiddleware` before `unkeyMiddleware` so the request can be authenticated before determining limits | ||
|
||
```file="web/entry.server.ts" | ||
import createSupabaseAuthMiddleware from '@redwoodjs/auth-supabase-middleware' | ||
import type { MiddlewareRequest } from '@redwoodjs/vite/middleware' | ||
import type { TagDescriptor } from '@redwoodjs/web' | ||
|
||
import App from './App' | ||
import { Document } from './Document' | ||
import withUnkey from '@unkey/redwoodjs' | ||
import type { withUnkeyOptions } from '@unkey/redwoodjs' | ||
|
||
// eslint-disable-next-line no-restricted-imports | ||
import { getCurrentUser } from '$api/src/lib/auth' | ||
|
||
interface Props { | ||
css: string[] | ||
meta?: TagDescriptor[] | ||
} | ||
|
||
export const supabaseRatelimitIdentifier = (req: MiddlewareRequest) => { | ||
const authContext = req?.serverAuthContext?.get() | ||
console.log('>>>> in supabaseRatelimitIdentifier', authContext) | ||
const identifier = authContext?.isAuthenticated | ||
? (authContext.currentUser?.sub as string) || 'anonymous-user' | ||
: '192.168.1.1' | ||
return identifier | ||
} | ||
|
||
export const registerMiddleware = () => { | ||
const options: withUnkeyOptions = { | ||
ratelimitConfig: { | ||
rootKey: process.env.UNKEY_ROOT_KEY, | ||
namespace: 'my-app', | ||
limit: 1, | ||
duration: '30s', | ||
async: true, | ||
}, | ||
matcher: ['/blog-post/:slug(\\d{1,})'], | ||
ratelimitIdentifierFn: supabaseRatelimitIdentifier, | ||
} | ||
const unkeyMiddleware = withUnkey(options) | ||
const supabaseAuthMiddleware = createSupabaseAuthMiddleware({ | ||
getCurrentUser, | ||
}) | ||
return [supabaseAuthMiddleware, unkeyMiddleware] | ||
} | ||
|
||
interface Props { | ||
css: string[] | ||
meta?: TagDescriptor[] | ||
} | ||
|
||
export const ServerEntry: React.FC<Props> = ({ css, meta }) => { | ||
return ( | ||
<Document css={css} meta={meta}> | ||
<App /> | ||
</Document> | ||
) | ||
} | ||
``` | ||
|
||
## Custom Rate Limit Exceeded Response | ||
|
||
TODO | ||
|
||
## Custom Rate Limit Error Response | ||
|
||
TODO |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
//import { Unkey } from "@unkey/api"; | ||
//import { version } from "../../package.json"; | ||
|
||
import { Ratelimit } from "@unkey/ratelimit"; | ||
import type { RatelimitConfig } from "@unkey/ratelimit"; | ||
|
||
import type { MiddlewareRequest } from "@redwoodjs/vite/dist/middleware"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Our RW middleware is still canary+ hence with install from a version 8+ redwood package and need /dist .. this should change in near future. |
||
import type { MiddlewareResponse } from "@redwoodjs/vite/dist/middleware"; | ||
|
||
import { | ||
defaultRatelimitErrorResponse, | ||
defaultRatelimitExceededResponse, | ||
defaultRatelimitIdentifier, | ||
matchesPath, | ||
} from "./util"; | ||
import type { MiddlewarePathMatcher } from "./util"; | ||
|
||
export type withUnkeyOptions = { | ||
ratelimitConfig: RatelimitConfig; | ||
matcher: MiddlewarePathMatcher; | ||
ratelimitIdentifierFn?: (req: MiddlewareRequest) => string; | ||
ratelimitExceededResponseFn?: (req: MiddlewareRequest) => MiddlewareResponse; | ||
ratelimitErrorResponseFn?: (req: MiddlewareRequest) => MiddlewareResponse; | ||
}; | ||
|
||
const withUnkey = (options: withUnkeyOptions) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comments and console removal to come. |
||
console.debug(">>>> in withUnkey createMiddleware", options); | ||
const unkey = new Ratelimit(options.ratelimitConfig); | ||
|
||
return async (req: MiddlewareRequest, res: MiddlewareResponse) => { | ||
const ratelimitIdentifier = options.ratelimitIdentifierFn || defaultRatelimitIdentifier; | ||
|
||
const rateLimitExceededResponse = | ||
options.ratelimitExceededResponseFn || defaultRatelimitExceededResponse; | ||
|
||
const rateLimitErrorResponse = | ||
options.ratelimitErrorResponseFn || defaultRatelimitErrorResponse; | ||
|
||
try { | ||
const url = new URL(req.url); | ||
const path = url.pathname; | ||
|
||
if (!matchesPath(path, options.matcher)) { | ||
console.debug(">>>> in withUnkey skip middleware for", req.url); | ||
return res; | ||
} | ||
|
||
const identifier = ratelimitIdentifier(req); | ||
|
||
console.debug(">>>> in withUnkey identifier", identifier); | ||
const ratelimit = await unkey.limit(identifier); | ||
|
||
if (!ratelimit.success) { | ||
console.error("Rate limit exceeded", ratelimit); | ||
const response = rateLimitExceededResponse(req); | ||
if (response.status !== 429) { | ||
console.warn("Rate limit exceeded response is not 429. Overriding status.", response); | ||
response.status = 429; | ||
} | ||
return response; | ||
} | ||
} catch (e) { | ||
console.error("Error in withUnkey", e); | ||
const response = rateLimitErrorResponse(req); | ||
if (response.status === 500) { | ||
console.warn( | ||
"Rate limit error response is 200 OK. Consider changing status to 500.", | ||
response, | ||
); | ||
} | ||
} | ||
|
||
console.debug(">>>> in withUnkey return response for", req.url); | ||
|
||
return res; | ||
}; | ||
}; | ||
|
||
export default withUnkey; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import { type MatchFunction, match } from "path-to-regexp"; | ||
|
||
import type { MiddlewareRequest } from "@redwoodjs/vite/dist/middleware"; | ||
import { MiddlewareResponse } from "@redwoodjs/vite/dist/middleware"; | ||
|
||
export type MiddlewarePathMatcher = string | string[]; | ||
|
||
export const defaultRatelimitIdentifier = (req: MiddlewareRequest) => { | ||
const authContext = req?.serverAuthContext?.get(); | ||
const identifier = authContext?.isAuthenticated | ||
? Buffer.from(JSON.stringify(authContext.currentUser)).toString("base64") | ||
: "192.168.1.1"; | ||
return identifier; | ||
}; | ||
|
||
export const matchesPath = (path: string, matcher: MiddlewarePathMatcher): boolean => { | ||
// Convert matcher to an array if it's not already one | ||
const matchers = Array.isArray(matcher) ? matcher : [matcher]; | ||
|
||
console.debug(">>>> in matchesPath", matchers, path); | ||
|
||
// Create a list of matching functions from the matchers | ||
const matchingFunctions: MatchFunction[] = matchers.map((pattern) => | ||
match(pattern, { decode: decodeURIComponent }), | ||
); | ||
|
||
// Check if the path matches any of the patterns | ||
return matchingFunctions.some((matchFunc) => matchFunc(path) !== false); | ||
}; | ||
|
||
export const defaultRatelimitExceededResponse = (_req: MiddlewareRequest) => { | ||
return new MiddlewareResponse("Rate limit exceeded", { status: 429 }); | ||
}; | ||
|
||
export const defaultRatelimitErrorResponse = (_req: MiddlewareRequest) => { | ||
return new MiddlewareResponse("Internal server error", { status: 500 }); | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@chronark I didn't realize this got committed. I'll remove.