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
3 changes: 3 additions & 0 deletions packages/wobe-documentation/doc/concepts/wobe.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ The `Wobe` constructor can have some options:
- `hostname`: The hostname where the server will be listening.
- `onError`: A function that will be called when an error occurs.
- `onNotFound`: A function that will be called when a route is not found.
- `maxBodySize`: Maximum accepted request body size in bytes (default: 1_048_576). Requests above the limit are rejected with `413`.
- `allowedContentEncodings`: Whitelisted `Content-Encoding` values (default: `['identity', '']`). Add `gzip`, `deflate` or `br` if you want to accept compressed bodies; decompressed size is still enforced by `maxBodySize`.
- `trustProxy`: When `true`, `X-Forwarded-For` is used to derive client IP (rate limiting/logs). Keep `false` if the app is directly reachable to avoid IP spoofing; enable only behind a trusted proxy that overwrites the header.
- `tls`: An object with the key, the cert and the passphrase if exist to enable HTTPS.

```ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const app = new Wobe()
.beforeHandler(
bearerAuth({
token: 'token',
}),
})
)
.get('/protected', (req, res) => {
res.send('Protected')
Expand All @@ -38,7 +38,7 @@ const app = new Wobe()
bearerAuth({
token: 'token',
hashFunction: (token) => token,
}),
})
)
.get('/protected', (req, res) => {
res.send('Protected')
Expand All @@ -57,4 +57,4 @@ const request = new Request('http://localhost:3000/test', {

- `token` (string) : The token to compare with the Authorization header.
- `realm` (string) : The realm to send in the WWW-Authenticate header.
- `hashFunction` ((token: string) => string) : A function to hash the token before comparing it.
- `hashFunction` ((token: string) => string) : A function to hash the token before comparing it. Tokens are compared in constant time; prefer a hash function that returns fixed-length output to avoid timing leaks on length.
2 changes: 2 additions & 0 deletions packages/wobe-documentation/doc/ecosystem/hooks/body-limit.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Wobe has a `beforeHandler` hook to put a limit to the body size of each requests.

Note: Wobe also enforces a global body size limit (default 1 MiB) at the adapter level via the `maxBodySize` option on `new Wobe({ ... })`. Use the hook if you need a tighter or route-specific limit.

## Example

```ts
Expand Down
10 changes: 8 additions & 2 deletions packages/wobe-documentation/doc/ecosystem/hooks/csrf.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

Wobe has a `beforeHandler` hook to manage CSRF.

Behavior:

- Enforced only on non-idempotent methods (POST/PUT/PATCH/DELETE/etc.).
- Uses `Origin` when present; falls back to checking `Referer` host when `Origin` is missing.
- Rejects with `403` when the origin/referer does not match the allowed list/function.

## Example

In this example all the requests without the origin equal to `http://localhost:3000` will be blocked.
Expand All @@ -22,7 +28,7 @@ import { Wobe, csrf } from 'wobe'

const app = new Wobe()
.beforeHandler(
csrf({ origin: ['http://localhost:3000', 'http://localhost:3001'] }),
csrf({ origin: ['http://localhost:3000', 'http://localhost:3001'] })
)
.get('/hello', (context) => context.res.sendText('Hello world'))
.listen(3000)
Expand All @@ -35,7 +41,7 @@ import { Wobe, csrf } from 'wobe'

const app = new Wobe()
.beforeHandler(
csrf({ origin: (origin) => origin === 'http://localhost:3000' }),
csrf({ origin: (origin) => origin === 'http://localhost:3000' })
)
.get('/hello', (context) => context.res.sendText('Hello world'))
.listen(3000)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ app.beforeHandler(
'default-src': ["'self'"],
'report-to': 'endpoint-5',
},
}),
})
)

app.get('/', (req, res) => {
Expand All @@ -35,3 +35,4 @@ app.listen(3000)
- `strictTransportSecurity` (string[]) : The Strict-Transport-Security header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)
- `xContentTypeOptions` (string) : The X-Content-Type-Options header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options)
- `xDownloadOptions` (string) : The X-Download-Options header value.
- `xFrameOptions` (string | false) : The X-Frame-Options header value. Defaults to `SAMEORIGIN`; set to `false` to disable (e.g., if you intentionally allow framing).
50 changes: 31 additions & 19 deletions packages/wobe-documentation/doc/ecosystem/hooks/upload-directory.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,53 +7,65 @@ Wobe provides an `uploadDirectory` hook to easily serve files from a specified d
A simple example to serve files from a directory.

```ts
import { Wobe, uploadDirectory } from 'wobe';
import { Wobe, uploadDirectory } from 'wobe'

const app = new Wobe()
.get('/bucket/:filename', uploadDirectory({
directory: './bucket',
}))
.listen(3000);
.get(
'/bucket/:filename',
uploadDirectory({
directory: './bucket',
})
)
.listen(3000)

// A request like this will serve the file `example.jpg` from the `./bucket` directory
const request = new Request('http://localhost:3000/bucket/example.jpg');
const request = new Request('http://localhost:3000/bucket/example.jpg')
```

## Options

- `directory` (string) : The directory path from which to serve files. This path should be relative to your project's root directory or an absolute path.
- `isAuthorized` (boolean) : A boolean value indicating whether the hook should check if the request is authorized. If set to `true`, the hook will be authorized to serve files, otherwise, it will be unauthorized. The default value is `true`. Usefull for example to allow access files only in development mode (with for example S3 storage on production).
- `allowSymlinks` (boolean) : Allow serving symlinks (default `false`). When `false`, symlinks are rejected.
- `allowDotfiles` (boolean) : Allow dotfiles like `.env` (default `false`). When `false`, dotfiles are hidden.

## Usage

To use the uploadDirectory hook, define a route in your Wobe application and pass the directory path as an option. The hook will handle requests to this route by serving the specified file from the directory.

```ts
import { Wobe, uploadDirectory } from 'wobe';
import { Wobe, uploadDirectory } from 'wobe'

const app = new Wobe()
.get('/bucket/:filename', uploadDirectory({
directory: './path/to/your/directory',
}))
.listen(3000);
.get(
'/bucket/:filename',
uploadDirectory({
directory: './path/to/your/directory',
})
)
.listen(3000)
```

## Error Handling

The `uploadDirectory` hook handles errors gracefully by providing appropriate HTTP responses for common issues:

- **Missing Filename Parameter**: If the `filename` parameter is missing in the request, the hook will respond with a `400 Bad Request` status and the message "Filename is required".
- **Missing Filename Parameter**: If the `filename` parameter is missing in the request, the hook will respond with a `400 Bad Request` status and the message "Filename is required".

```ts
const response = await fetch('http://localhost:3000/bucket/');
console.log(response.status); // 400
console.log(await response.text()); // "Filename is required"
const response = await fetch('http://localhost:3000/bucket/')
console.log(response.status) // 400
console.log(await response.text()) // "Filename is required"
```

- **File Not Found**: If the file specified by the `filename` parameter does not exist in the directory, the hook will respond with a `404 Not Found` status and the message "File not found".
- **File Not Found**: If the file specified by the `filename` parameter does not exist in the directory, the hook will respond with a `404 Not Found` status and the message "File not found".

```ts
const response = await fetch('http://localhost:3000/bucket/non-existent-file.txt');
console.log(response.status); // 404
console.log(await response.text()); // "File not found"
const response = await fetch(
'http://localhost:3000/bucket/non-existent-file.txt'
)
console.log(response.status) // 404
console.log(await response.text()) // "File not found"
```

- **Traversal or forbidden path**: Paths that escape the configured directory (e.g., `../secret`) return `403 Forbidden`. By default, dotfiles and symlinks are also blocked unless explicitly allowed.
11 changes: 11 additions & 0 deletions packages/wobe/src/Wobe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ describe('Wobe', () => {
.get('/test', (ctx) => {
return ctx.res.send('Test')
})
.post('/test', (ctx) => {
return ctx.res.send('Test')
})
.post('/testRequestBodyCache', async (ctx) => {
return ctx.res.send(await ctx.request.text())
})
Expand Down Expand Up @@ -161,6 +164,10 @@ describe('Wobe', () => {
'/test/',
csrf({ origin: `http://127.0.0.1:${port}` }),
)
.beforeHandler(
'/test',
csrf({ origin: `http://127.0.0.1:${port}` }),
)
.beforeAndAfterHandler(logger())
.beforeHandler('/testBearer', bearerAuth({ token: '123' }))
.beforeHandler('/test/*', mockHookWithWildcardRoute)
Expand Down Expand Up @@ -467,19 +474,23 @@ describe('Wobe', () => {

it('should not block requests with valid origin', async () => {
const res = await fetch(`http://127.0.0.1:${port}/test`, {
method: 'POST',
headers: {
origin: `http://127.0.0.1:${port}`,
},
body: 'payload',
})

expect(res.status).toBe(200)
})

it('should block requests with invalid origin', async () => {
const res = await fetch(`http://127.0.0.1:${port}/test`, {
method: 'POST',
headers: {
origin: 'invalid-origin',
},
body: 'payload',
})

expect(res.status).toBe(403)
Expand Down
23 changes: 22 additions & 1 deletion packages/wobe/src/Wobe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ export interface WobeOptions {
hostname?: string
onError?: (error: Error) => void
onNotFound?: (request: Request) => void
/**
* Maximum accepted body size in bytes (default: 1 MiB).
* Used by adapters to reject overly large requests early.
*/
maxBodySize?: number
/**
* Allowed content-encodings. If undefined, only identity/empty is allowed.
* Example: ['identity', 'gzip', 'deflate'].
*/
allowedContentEncodings?: string[]
/**
* Trust proxy headers (X-Forwarded-For) for client IP detection.
* Default false to avoid spoofing.
*/
trustProxy?: boolean
tls?: {
key: string
cert: string
Expand Down Expand Up @@ -268,7 +283,13 @@ export class Wobe<T> {
* @param webSocketHandler The WebSocket handler
*/
useWebSocket(webSocketHandler: WobeWebSocket) {
this.webSocket = webSocketHandler
this.webSocket = {
maxPayloadLength: 1024 * 1024, // 1 MiB
idleTimeout: 60,
backpressureLimit: 1024 * 1024,
closeOnBackpressureLimit: true,
...webSocketHandler,
}

return this
}
Expand Down
28 changes: 28 additions & 0 deletions packages/wobe/src/WobeResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,34 @@ describe('Wobe Response', () => {
)
})

it('should reject invalid cookie name', () => {
const wobeResponse = new WobeResponse(
new Request('http://localhost:3000/test'),
)

expect(() => wobeResponse.setCookie('bad name', 'value')).toThrow()
})

it('should reject cookie value with CRLF', () => {
const wobeResponse = new WobeResponse(
new Request('http://localhost:3000/test'),
)

expect(() => wobeResponse.setCookie('safe', 'val\r\nue')).toThrow()
})

it('should encode dangerous cookie value', () => {
const wobeResponse = new WobeResponse(
new Request('http://localhost:3000/test'),
)

wobeResponse.setCookie('safe', 'value;inject')

expect(wobeResponse.headers?.get('Set-Cookie')).toBe(
'safe=value%3Binject;',
)
})

it('should delete a cookie from a response', () => {
const wobeResponse = new WobeResponse(
new Request('http://localhost:3000/test'),
Expand Down
20 changes: 19 additions & 1 deletion packages/wobe/src/WobeResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ export class WobeResponse {
public status = 200
public statusText = 'OK'

private static isCookieNameValid(name: string) {
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)
}

private static sanitizeCookieValue(value: string) {
if (/[\r\n]/.test(value))
throw new Error('Invalid cookie value: contains CR/LF')

if (value.includes(';')) return encodeURIComponent(value) // avoid header injection

return value
}

constructor(request: Request) {
this.request = request
}
Expand Down Expand Up @@ -46,7 +59,12 @@ export class WobeResponse {
* @param options The options of the cookie
*/
setCookie(name: string, value: string, options?: SetCookieOptions) {
let cookie = `${name}=${value};`
if (!WobeResponse.isCookieNameValid(name))
throw new Error('Invalid cookie name')

const safeValue = WobeResponse.sanitizeCookieValue(value)

let cookie = `${name}=${safeValue};`

if (options) {
const {
Expand Down
Loading