Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ jobs:
testing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
- name: Setup Environment (Using NodeJS 22.x)
uses: actions/setup-node@v1
uses: actions/setup-node@v4
with:
node-version: 22.x

- name: Install dependencies
run: npm install

- name: Linting
run: npm run format
run: npx prettier --check *.js

- name: Run tests
run: npm run test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,4 @@ dist
*-lock.json
bun.lockb

sec-findings.md
53 changes: 46 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ This middleware enables your API to handle requests idempotently, ensuring that
## Features

- **Idempotent Request Handling**: Ensures that duplicate requests with the same idempotency key are processed only once, preventing unintended side effects.
- **Original Response Replay**: On a cache hit, the middleware replays the original HTTP status code, headers, and body instead of returning a generic empty response.
- **Concurrent Request Deduplication**: Uses an in-flight lock so that simultaneous requests with the same idempotency key execute the handler only once.
- **Cache Key Scoping**: Cache keys are derived from the HTTP method, full request URL (including query string), and idempotency key to prevent cross-route and cross-method collisions.
- **Customizable Cache Integration**: Supports any cache library that implements `get` and `set` methods, allowing flexibility in your caching strategy.
- **Configurable Idempotency Key**: Lets you define the key used to identify requests. By default, it uses the `x-request-id` header.
- **Adjustable TTL (Time-to-Live)**: Provides the ability to configure the expiration time for cache entries, balancing performance and resource usage.
- **Adjustable TTL (Time-to-Live)**: Provides the ability to configure the expiration time for cache entries, balancing performance and resource usage (max 24 hours).
- **HTTP Method Support**: Compatible with the following HTTP methods: `POST`, `PUT`, `PATCH`, and `DELETE`.

## Installation
Expand Down Expand Up @@ -63,32 +66,59 @@ Calling the API

```bash
curl -X POST http://localhost:3000/create -H "x-request-id: 123" # 200 -> Resource created!
curl -X POST http://localhost:3000/create -H "x-request-id: 123" # 204
curl -X POST http://localhost:3000/create -H "x-request-id: 123" # 200 -> Resource created! (replayed from cache)
# after 5 seconds
curl -X POST http://localhost:3000/create -H "x-request-id: 123" # 200 -> Resource created!
```

## Options

| Option | Type | Default | Description |
| ------------------------- | -------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `cache` | `Cache` | required | A cache instance with `.get(key)` and `.set(key, value, {ttl})` methods. |
| `ttl` | `number` | required | Cache TTL in milliseconds. Must be between `1` and `86,400,000` (24 hours). |
| `idempotencyKeyExtractor` | `(req) => string \| undefined \| null` | `req.headers['x-request-id']` | Extracts the idempotency key from the request. The returned key must match `^[a-zA-Z0-9_.~-]{1,128}$`. Duplicate headers are exposed as arrays by Node.js; the default extractor does not normalize them, so idempotency is skipped for such requests. |
| `keyPrefix` | `string` | `'idemp-key-'` | Prefix prepended to every cache key. |
| `maxResponseSize` | `number` | `1,048,576` (1 MB) | Maximum response body size (in bytes) that will be cached. Larger responses are not cached. |
| `logger` | `Logger` | `console` | Logger used for error reporting. Must expose an `.error(...args)` method. |

### Behavior notes

- Only successful responses with a `2xx` status code are cached.
- Hop-by-hop and connection-level headers such as `Connection`, `Keep-Alive`, `Transfer-Encoding`, `Content-Length`, and `Date` are stripped before replay and are not restored from the cache.
- Responses larger than `maxResponseSize` are still served to the client; only the cache write is skipped.
- Previous versions stored a plain string (`"1"`) in the cache. Those entries are ignored after upgrading, so only new responses will be replayed.

## Customizing idempotency key

By default, the middleware uses the `x-request-id` header to identify the request. You can customize the key that will be used to identify the request by passing a custom `idempotencyKeyExtractor` function to the middleware.

> In production environments, it is recommended to use a combination of the `x-request-id` header and other unique identifiers such as `service-name` and `user-id` to ensure the key's uniqueness and prevent collisions.
> In production environments, it is **strongly recommended** to combine the `x-request-id` header with user/tenant identifiers (e.g., a hashed user ID or session token) to ensure the key's uniqueness and prevent cross-user collisions.

```javascript
function extractIdempotencyKey(req: Request) {
const header = req.headers['x-custom-req-id']
const value = Array.isArray(header) ? header[0] : header
if (!value || !/^[a-zA-Z0-9_.~-]{1,128}$/.test(value)) {
return undefined
}
// Scope the key with a service and user identifier to prevent cross-user collisions.
const userId = req.user?.id ?? 'anonymous'
return `${SERVICE_NAME}-${userId}-${value}`
}

app.use(
idempotencyMiddleware({
ttl: 5000,
idempotencyKeyExtractor: (req: Request) => {
return `${SERVICE_NAME}-${req.headers['x-custom-req-id']}`
},
idempotencyKeyExtractor: extractIdempotencyKey,
//...,
}),
)
```

### Security Considerations

The middleware is designed to operate in a trusted environment. If you plan to deploy it in an untrusted or partially trusted environment, take the following risks and mitigations into account:
The middleware is designed to operate safely in untrusted or partially trusted environments when configured correctly. Keep the following risks and mitigations in mind:

#### 1. **Cache Flooding**

Expand All @@ -98,6 +128,7 @@ An attacker could overwhelm the cache by sending a high volume of requests with

- Implement rate limiting and throttling mechanisms at the middleware or API gateway level.
- Set a maximum capacity for the idempotency cache, with a defined eviction policy (e.g., Least Recently Used (LRU) strategy).
- Use the `maxResponseSize` option to avoid caching very large responses.
- Monitor and log unusual traffic patterns to detect and respond to potential attacks promptly.

#### 2. **Identity Spoofing**
Expand All @@ -109,6 +140,14 @@ An attacker could forge the `x-request-id` header to impersonate another user's
- Use a secure idempotency key that combines the `x-request-id` header with user-specific information, such as a hashed user identifier or session token.
- Encrypt or digitally sign the `x-request-id` value to ensure its authenticity and prevent tampering.

#### 3. **Concurrent Duplicate Processing**

Without locking, two simultaneous requests with the same idempotency key could both execute the underlying handler.

**Mitigation:**

- This middleware now maintains an in-flight lock per idempotency key so that duplicate concurrent requests wait for the first request to finish and then replay its cached response.

#### General Recommendations

- Regularly audit the middleware's security practices and ensure compliance with your organization's security standards.
Expand Down
19 changes: 17 additions & 2 deletions demos/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ const {CacheableMemory} = require('cacheable')

const SERVICE_NAME = 'express-demo'

function getCurrentUserId(req) {
// Replace this with the real authenticated user identifier.
// In Express you typically read it from req.user after authentication.
return req.user?.id ?? 'anonymous'
}

const cache = createCache({
stores: [
new Keyv({
Expand All @@ -15,13 +21,22 @@ const cache = createCache({
],
})

function extractIdempotencyKey(req) {
const header = req.headers['x-custom-req-id']
const value = Array.isArray(header) ? header[0] : header
if (!value || !/^[a-zA-Z0-9_.~-]{1,128}$/.test(value)) {
return undefined
}
// Scope the key with a service and user identifier to prevent cross-user collisions.
return `${SERVICE_NAME}-${getCurrentUserId(req)}-${value}`
}

const app = express()

app.use(
idempotencyMiddleware({
ttl: 5000,
idempotencyKeyExtractor: (req) =>
`${SERVICE_NAME}-${req.headers['x-custom-req-id']}`,
idempotencyKeyExtractor: extractIdempotencyKey,
cache: {
get: async (key) => {
return cache.get(key)
Expand Down
19 changes: 17 additions & 2 deletions demos/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import {CacheableMemory} from 'cacheable'

const SERVICE_NAME = 'express-demo'

function getCurrentUserId(req: Request): string {
// Replace this with the real authenticated user identifier.
// In Express you typically read it from req.user after authentication.
return (req as Request & {user?: {id: string}}).user?.id ?? 'anonymous'
}

const cache = createCache({
stores: [
new Keyv({
Expand All @@ -15,13 +21,22 @@ const cache = createCache({
],
})

function extractIdempotencyKey(req: Request): string | undefined {
const header = req.headers['x-custom-req-id']
const value = Array.isArray(header) ? header[0] : header
if (!value || !/^[a-zA-Z0-9_.~-]{1,128}$/.test(value)) {
return undefined
}
// Scope the key with a service and user identifier to prevent cross-user collisions.
return `${SERVICE_NAME}-${getCurrentUserId(req)}-${value}`
}

const app = express()

app.use(
idempotencyMiddleware({
ttl: 5000,
idempotencyKeyExtractor: (req) =>
`${SERVICE_NAME}-${req.headers['x-custom-req-id']}`,
idempotencyKeyExtractor: extractIdempotencyKey,
cache: {
get: async (key: string) => {
return cache.get(key)
Expand Down
36 changes: 31 additions & 5 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,22 @@ export interface IdempotencyMiddlewareOptions {

/**
* Default time-to-live for cached responses, in milliseconds.
* Must be between 1 and 86,400,000 (24 hours).
*/
ttl: number

/**
* A function to extract the idempotency key from the HTTP request.
* Defaults to extracting the `x-request-id` header.
*
* The returned key must be a non-empty string of at most 128 characters
* matching `^[a-zA-Z0-9_.~-]+$`. In multi-user environments the key should
* be scoped with a user/tenant identifier to prevent cross-user collisions.
*
* @param req - The incoming HTTP request.
* @returns The extracted idempotency key as a string.
* @returns The extracted idempotency key, or `undefined`/`null` to disable idempotency.
*/
idempotencyKeyExtractor?: (req: IncomingMessage) => string
idempotencyKeyExtractor?: (req: IncomingMessage) => string | undefined | null

/**
* An optional logger for error reporting.
Expand All @@ -80,25 +85,46 @@ export interface IdempotencyMiddlewareOptions {

/**
* A prefix to prepend to cache keys to avoid collisions with other cached data.
* Defaults to `'idempotent-req-'`.
* Defaults to `'idemp-key-'`.
*/
keyPrefix?: string

/**
* Maximum response body size (in bytes) that will be cached.
* Responses larger than this value are not cached, preventing cache memory exhaustion.
* Defaults to 1 MB (1,048,576 bytes).
*/
maxResponseSize?: number

/**
* Maximum time (in milliseconds) to wait for a cache read before giving up and
* calling the next handler. Defaults to 5,000 ms.
*/
cacheTimeout?: number
}

/**
* Creates an idempotency middleware function.
*
* The middleware ensures idempotent handling of requests by:
* - Checking if a response for a unique request key (idempotency key) is cached.
* - Returning a cached response if available, skipping reprocessing.
* - Returning the cached response if available, skipping reprocessing.
* - Caching successful responses (status codes 2xx) for future requests.
*
* The cache key is scoped by HTTP method, request URL, and the extracted idempotency
* key, and the middleware uses an in-flight lock to prevent concurrent duplicate
* processing of the same key.
*
* @param options - Configuration options for the middleware.
* @returns A middleware function compatible with Connect-like frameworks.
*/
export function idempotencyMiddleware(
options: IdempotencyMiddlewareOptions,
): (req: IncomingMessage, res: ServerResponse, next: () => void) => void
): (
req: IncomingMessage,
res: ServerResponse,
next: (err?: any) => void,
) => void

/**
* Generates a SHA-256 hash of the input string.
Expand Down
Loading
Loading