Skip to content

Commit ee48efb

Browse files
authored
feat(wobe): improve security (#56)
* feat(wobe): improve security * fix: error on node test
1 parent 2f09b57 commit ee48efb

22 files changed

Lines changed: 861 additions & 63 deletions

packages/wobe-documentation/doc/concepts/wobe.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ The `Wobe` constructor can have some options:
1717
- `hostname`: The hostname where the server will be listening.
1818
- `onError`: A function that will be called when an error occurs.
1919
- `onNotFound`: A function that will be called when a route is not found.
20+
- `maxBodySize`: Maximum accepted request body size in bytes (default: 1_048_576). Requests above the limit are rejected with `413`.
21+
- `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`.
22+
- `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.
2023
- `tls`: An object with the key, the cert and the passphrase if exist to enable HTTPS.
2124

2225
```ts

packages/wobe-documentation/doc/ecosystem/hooks/bearer-auth.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const app = new Wobe()
1313
.beforeHandler(
1414
bearerAuth({
1515
token: 'token',
16-
}),
16+
})
1717
)
1818
.get('/protected', (req, res) => {
1919
res.send('Protected')
@@ -38,7 +38,7 @@ const app = new Wobe()
3838
bearerAuth({
3939
token: 'token',
4040
hashFunction: (token) => token,
41-
}),
41+
})
4242
)
4343
.get('/protected', (req, res) => {
4444
res.send('Protected')
@@ -57,4 +57,4 @@ const request = new Request('http://localhost:3000/test', {
5757

5858
- `token` (string) : The token to compare with the Authorization header.
5959
- `realm` (string) : The realm to send in the WWW-Authenticate header.
60-
- `hashFunction` ((token: string) => string) : A function to hash the token before comparing it.
60+
- `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.

packages/wobe-documentation/doc/ecosystem/hooks/body-limit.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

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

5+
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.
6+
57
## Example
68

79
```ts

packages/wobe-documentation/doc/ecosystem/hooks/csrf.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
Wobe has a `beforeHandler` hook to manage CSRF.
44

5+
Behavior:
6+
7+
- Enforced only on non-idempotent methods (POST/PUT/PATCH/DELETE/etc.).
8+
- Uses `Origin` when present; falls back to checking `Referer` host when `Origin` is missing.
9+
- Rejects with `403` when the origin/referer does not match the allowed list/function.
10+
511
## Example
612

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

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

3642
const app = new Wobe()
3743
.beforeHandler(
38-
csrf({ origin: (origin) => origin === 'http://localhost:3000' }),
44+
csrf({ origin: (origin) => origin === 'http://localhost:3000' })
3945
)
4046
.get('/hello', (context) => context.res.sendText('Hello world'))
4147
.listen(3000)

packages/wobe-documentation/doc/ecosystem/hooks/secure-headers.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ app.beforeHandler(
1515
'default-src': ["'self'"],
1616
'report-to': 'endpoint-5',
1717
},
18-
}),
18+
})
1919
)
2020

2121
app.get('/', (req, res) => {
@@ -35,3 +35,4 @@ app.listen(3000)
3535
- `strictTransportSecurity` (string[]) : The Strict-Transport-Security header value. [For more informations](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)
3636
- `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)
3737
- `xDownloadOptions` (string) : The X-Download-Options header value.
38+
- `xFrameOptions` (string | false) : The X-Frame-Options header value. Defaults to `SAMEORIGIN`; set to `false` to disable (e.g., if you intentionally allow framing).

packages/wobe-documentation/doc/ecosystem/hooks/upload-directory.md

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,53 +7,65 @@ Wobe provides an `uploadDirectory` hook to easily serve files from a specified d
77
A simple example to serve files from a directory.
88

99
```ts
10-
import { Wobe, uploadDirectory } from 'wobe';
10+
import { Wobe, uploadDirectory } from 'wobe'
1111

1212
const app = new Wobe()
13-
.get('/bucket/:filename', uploadDirectory({
14-
directory: './bucket',
15-
}))
16-
.listen(3000);
13+
.get(
14+
'/bucket/:filename',
15+
uploadDirectory({
16+
directory: './bucket',
17+
})
18+
)
19+
.listen(3000)
1720

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

2225
## Options
2326

2427
- `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.
2528
- `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).
29+
- `allowSymlinks` (boolean) : Allow serving symlinks (default `false`). When `false`, symlinks are rejected.
30+
- `allowDotfiles` (boolean) : Allow dotfiles like `.env` (default `false`). When `false`, dotfiles are hidden.
2631

2732
## Usage
2833

2934
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.
3035

3136
```ts
32-
import { Wobe, uploadDirectory } from 'wobe';
37+
import { Wobe, uploadDirectory } from 'wobe'
3338

3439
const app = new Wobe()
35-
.get('/bucket/:filename', uploadDirectory({
36-
directory: './path/to/your/directory',
37-
}))
38-
.listen(3000);
40+
.get(
41+
'/bucket/:filename',
42+
uploadDirectory({
43+
directory: './path/to/your/directory',
44+
})
45+
)
46+
.listen(3000)
3947
```
4048

4149
## Error Handling
4250

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

45-
- **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".
53+
- **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".
4654

4755
```ts
48-
const response = await fetch('http://localhost:3000/bucket/');
49-
console.log(response.status); // 400
50-
console.log(await response.text()); // "Filename is required"
56+
const response = await fetch('http://localhost:3000/bucket/')
57+
console.log(response.status) // 400
58+
console.log(await response.text()) // "Filename is required"
5159
```
5260

53-
- **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".
61+
- **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".
5462

5563
```ts
56-
const response = await fetch('http://localhost:3000/bucket/non-existent-file.txt');
57-
console.log(response.status); // 404
58-
console.log(await response.text()); // "File not found"
64+
const response = await fetch(
65+
'http://localhost:3000/bucket/non-existent-file.txt'
66+
)
67+
console.log(response.status) // 404
68+
console.log(await response.text()) // "File not found"
5969
```
70+
71+
- **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.

packages/wobe/src/Wobe.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ describe('Wobe', () => {
9595
.get('/test', (ctx) => {
9696
return ctx.res.send('Test')
9797
})
98+
.post('/test', (ctx) => {
99+
return ctx.res.send('Test')
100+
})
98101
.post('/testRequestBodyCache', async (ctx) => {
99102
return ctx.res.send(await ctx.request.text())
100103
})
@@ -161,6 +164,10 @@ describe('Wobe', () => {
161164
'/test/',
162165
csrf({ origin: `http://127.0.0.1:${port}` }),
163166
)
167+
.beforeHandler(
168+
'/test',
169+
csrf({ origin: `http://127.0.0.1:${port}` }),
170+
)
164171
.beforeAndAfterHandler(logger())
165172
.beforeHandler('/testBearer', bearerAuth({ token: '123' }))
166173
.beforeHandler('/test/*', mockHookWithWildcardRoute)
@@ -467,19 +474,23 @@ describe('Wobe', () => {
467474

468475
it('should not block requests with valid origin', async () => {
469476
const res = await fetch(`http://127.0.0.1:${port}/test`, {
477+
method: 'POST',
470478
headers: {
471479
origin: `http://127.0.0.1:${port}`,
472480
},
481+
body: 'payload',
473482
})
474483

475484
expect(res.status).toBe(200)
476485
})
477486

478487
it('should block requests with invalid origin', async () => {
479488
const res = await fetch(`http://127.0.0.1:${port}/test`, {
489+
method: 'POST',
480490
headers: {
481491
origin: 'invalid-origin',
482492
},
493+
body: 'payload',
483494
})
484495

485496
expect(res.status).toBe(403)

packages/wobe/src/Wobe.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ export interface WobeOptions {
99
hostname?: string
1010
onError?: (error: Error) => void
1111
onNotFound?: (request: Request) => void
12+
/**
13+
* Maximum accepted body size in bytes (default: 1 MiB).
14+
* Used by adapters to reject overly large requests early.
15+
*/
16+
maxBodySize?: number
17+
/**
18+
* Allowed content-encodings. If undefined, only identity/empty is allowed.
19+
* Example: ['identity', 'gzip', 'deflate'].
20+
*/
21+
allowedContentEncodings?: string[]
22+
/**
23+
* Trust proxy headers (X-Forwarded-For) for client IP detection.
24+
* Default false to avoid spoofing.
25+
*/
26+
trustProxy?: boolean
1227
tls?: {
1328
key: string
1429
cert: string
@@ -268,7 +283,13 @@ export class Wobe<T> {
268283
* @param webSocketHandler The WebSocket handler
269284
*/
270285
useWebSocket(webSocketHandler: WobeWebSocket) {
271-
this.webSocket = webSocketHandler
286+
this.webSocket = {
287+
maxPayloadLength: 1024 * 1024, // 1 MiB
288+
idleTimeout: 60,
289+
backpressureLimit: 1024 * 1024,
290+
closeOnBackpressureLimit: true,
291+
...webSocketHandler,
292+
}
272293

273294
return this
274295
}

packages/wobe/src/WobeResponse.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,34 @@ describe('Wobe Response', () => {
256256
)
257257
})
258258

259+
it('should reject invalid cookie name', () => {
260+
const wobeResponse = new WobeResponse(
261+
new Request('http://localhost:3000/test'),
262+
)
263+
264+
expect(() => wobeResponse.setCookie('bad name', 'value')).toThrow()
265+
})
266+
267+
it('should reject cookie value with CRLF', () => {
268+
const wobeResponse = new WobeResponse(
269+
new Request('http://localhost:3000/test'),
270+
)
271+
272+
expect(() => wobeResponse.setCookie('safe', 'val\r\nue')).toThrow()
273+
})
274+
275+
it('should encode dangerous cookie value', () => {
276+
const wobeResponse = new WobeResponse(
277+
new Request('http://localhost:3000/test'),
278+
)
279+
280+
wobeResponse.setCookie('safe', 'value;inject')
281+
282+
expect(wobeResponse.headers?.get('Set-Cookie')).toBe(
283+
'safe=value%3Binject;',
284+
)
285+
})
286+
259287
it('should delete a cookie from a response', () => {
260288
const wobeResponse = new WobeResponse(
261289
new Request('http://localhost:3000/test'),

packages/wobe/src/WobeResponse.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ export class WobeResponse {
1515
public status = 200
1616
public statusText = 'OK'
1717

18+
private static isCookieNameValid(name: string) {
19+
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)
20+
}
21+
22+
private static sanitizeCookieValue(value: string) {
23+
if (/[\r\n]/.test(value))
24+
throw new Error('Invalid cookie value: contains CR/LF')
25+
26+
if (value.includes(';')) return encodeURIComponent(value) // avoid header injection
27+
28+
return value
29+
}
30+
1831
constructor(request: Request) {
1932
this.request = request
2033
}
@@ -46,7 +59,12 @@ export class WobeResponse {
4659
* @param options The options of the cookie
4760
*/
4861
setCookie(name: string, value: string, options?: SetCookieOptions) {
49-
let cookie = `${name}=${value};`
62+
if (!WobeResponse.isCookieNameValid(name))
63+
throw new Error('Invalid cookie name')
64+
65+
const safeValue = WobeResponse.sanitizeCookieValue(value)
66+
67+
let cookie = `${name}=${safeValue};`
5068

5169
if (options) {
5270
const {

0 commit comments

Comments
 (0)