@vyckr/tachyon 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -3
- package/README.md +43 -8
- package/package.json +1 -1
- package/src/cli/serve.ts +7 -1
- package/src/compiler/render-template.js +17 -12
- package/src/compiler/template-compiler.ts +10 -6
- package/src/runtime/spa-renderer.ts +2 -1
- package/src/server/process-executor.ts +30 -11
- package/src/server/process-pool.ts +29 -8
- package/src/server/route-handler.ts +20 -1
package/.env.example
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
# Tachyon environment variables
|
|
2
2
|
PORT=8000
|
|
3
3
|
TIMEOUT=70
|
|
4
|
+
# Maximum ms a handler process may run before it is killed (default: 30000)
|
|
5
|
+
HANDLER_TIMEOUT_MS=30000
|
|
4
6
|
DEV=
|
|
5
7
|
VALIDATE=
|
|
6
8
|
HOSTNAME=127.0.0.1
|
|
7
9
|
ALLOW_HEADERS=*
|
|
8
|
-
|
|
10
|
+
# Restrict to explicit origins in production — never use * with credentials enabled
|
|
11
|
+
ALLOW_ORIGINS=https://yourdomain.com
|
|
9
12
|
ALLOW_CREDENTIALS=
|
|
10
|
-
ALLOW_EXPOSE_HEADERS
|
|
13
|
+
ALLOW_EXPOSE_HEADERS=
|
|
11
14
|
ALLOW_MAX_AGE=3600
|
|
12
15
|
ALLOW_METHODS=GET,POST,PUT,DELETE,PATCH,OPTIONS
|
|
13
|
-
|
|
16
|
+
# Generate strong credentials at deployment time — never commit real credentials
|
|
17
|
+
BASIC_AUTH=
|
|
18
|
+
# Override default CSP if your app loads scripts/styles from external origins
|
|
19
|
+
CONTENT_SECURITY_POLICY=default-src 'self'
|
package/README.md
CHANGED
|
@@ -13,7 +13,8 @@ Tachyon is a **polyglot, file-system-routed full-stack framework for [Bun](https
|
|
|
13
13
|
- **Custom 404 page** — drop a `404.html` in your project root to override the default
|
|
14
14
|
- **Schema validation** — per-route request/response validation via `OPTIONS` files
|
|
15
15
|
- **Status code routing** — map response schemas to HTTP status codes; the framework picks the code automatically
|
|
16
|
-
- **Auth** — built-in Basic Auth and JWT decoding
|
|
16
|
+
- **Auth** — built-in Basic Auth (timing-safe) and JWT decoding with expiry enforcement
|
|
17
|
+
- **Security headers** — X-Frame-Options, X-Content-Type-Options, HSTS, CSP, and Referrer-Policy sent on every response
|
|
17
18
|
- **Streaming** — SSE responses via `Accept: text/event-stream`
|
|
18
19
|
|
|
19
20
|
## Installation
|
|
@@ -53,20 +54,28 @@ HOSTNAME=127.0.0.1
|
|
|
53
54
|
TIMEOUT=70
|
|
54
55
|
DEV=true
|
|
55
56
|
|
|
56
|
-
# CORS
|
|
57
|
-
ALLOW_HEADERS
|
|
58
|
-
ALLOW_ORIGINS
|
|
57
|
+
# CORS — restrict to explicit origins in production; never combine * with credentials
|
|
58
|
+
ALLOW_HEADERS=Content-Type,Authorization
|
|
59
|
+
ALLOW_ORIGINS=https://yourdomain.com
|
|
59
60
|
ALLOW_CREDENTIALS=false
|
|
60
|
-
ALLOW_EXPOSE_HEADERS
|
|
61
|
+
ALLOW_EXPOSE_HEADERS=
|
|
61
62
|
ALLOW_MAX_AGE=3600
|
|
62
63
|
ALLOW_METHODS=GET,POST,PUT,DELETE,PATCH,OPTIONS
|
|
63
64
|
|
|
64
|
-
# Auth
|
|
65
|
-
BASIC_AUTH=
|
|
65
|
+
# Auth — generate strong credentials; never commit real values
|
|
66
|
+
BASIC_AUTH=
|
|
66
67
|
|
|
67
68
|
# Validation (set to any value to enable)
|
|
68
69
|
VALIDATE=true
|
|
69
70
|
|
|
71
|
+
# Security
|
|
72
|
+
# Override the default CSP if your app loads scripts/styles from external origins
|
|
73
|
+
CONTENT_SECURITY_POLICY=default-src 'self'
|
|
74
|
+
# Maximum ms a handler process may run before it is killed (default: 30000)
|
|
75
|
+
HANDLER_TIMEOUT_MS=30000
|
|
76
|
+
# Maximum length of any single route or query parameter value (default: 1000)
|
|
77
|
+
MAX_PARAM_LENGTH=1000
|
|
78
|
+
|
|
70
79
|
# Custom route/asset paths (defaults to <cwd>/routes, <cwd>/components, <cwd>/assets)
|
|
71
80
|
ROUTES_PATH=
|
|
72
81
|
COMPONENTS_PATH=
|
|
@@ -111,11 +120,18 @@ Every handler receives the full request context on `stdin` as a JSON object:
|
|
|
111
120
|
"paths": { "version": "v2" },
|
|
112
121
|
"context": {
|
|
113
122
|
"ipAddress": "127.0.0.1",
|
|
114
|
-
"bearer":
|
|
123
|
+
"bearer": {
|
|
124
|
+
"header": { "alg": "HS256", "typ": "JWT" },
|
|
125
|
+
"payload": { "sub": "42", "exp": 1999999999 },
|
|
126
|
+
"signature": "...",
|
|
127
|
+
"verified": false
|
|
128
|
+
}
|
|
115
129
|
}
|
|
116
130
|
}
|
|
117
131
|
```
|
|
118
132
|
|
|
133
|
+
> **Note:** `context.bearer` is decoded but the signature is **not** verified. `verified` is always `false`. Do not trust claims in `bearer.payload` without out-of-band verification (e.g. via middleware that calls a trusted auth service). Tokens with an expired `exp` claim are rejected before reaching your handler. Use the [`jose`](https://github.com/panva/jose) library for full JWT verification.
|
|
134
|
+
|
|
119
135
|
### Route Handler Examples
|
|
120
136
|
|
|
121
137
|
**Bun (TypeScript)**
|
|
@@ -292,6 +308,25 @@ tach.bundle
|
|
|
292
308
|
|
|
293
309
|
Outputs compiled assets to `dist/`.
|
|
294
310
|
|
|
311
|
+
## Security
|
|
312
|
+
|
|
313
|
+
Tachyon applies the following protections by default:
|
|
314
|
+
|
|
315
|
+
| Area | Protection |
|
|
316
|
+
|------|-----------|
|
|
317
|
+
| **Response headers** | `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Strict-Transport-Security`, `Content-Security-Policy`, `Referrer-Policy` on every response |
|
|
318
|
+
| **Basic Auth** | Credential comparison uses `timingSafeEqual` to prevent timing oracle attacks |
|
|
319
|
+
| **JWT** | Tokens with an expired `exp` claim are rejected; signature is decoded but not verified (see note above) |
|
|
320
|
+
| **Process timeout** | Handler processes that exceed `HANDLER_TIMEOUT_MS` are killed automatically |
|
|
321
|
+
| **Parameter limits** | Query and path parameters exceeding `MAX_PARAM_LENGTH` characters return HTTP 400 |
|
|
322
|
+
| **Error responses** | Unhandled server errors return a generic message; internal details are logged server-side only |
|
|
323
|
+
| **CORS** | Wildcard `ALLOW_ORIGINS=*` combined with `ALLOW_CREDENTIALS=true` is not recommended — set explicit origins in production |
|
|
324
|
+
|
|
325
|
+
For production deployments:
|
|
326
|
+
- Set `BASIC_AUTH` to a strong credential — never use a default value
|
|
327
|
+
- Set `ALLOW_ORIGINS` to your application's domain instead of `*`
|
|
328
|
+
- Consider adding a reverse proxy (nginx, Caddy) to enforce HTTPS and add rate limiting
|
|
329
|
+
|
|
295
330
|
## License
|
|
296
331
|
|
|
297
332
|
MIT
|
package/package.json
CHANGED
package/src/cli/serve.ts
CHANGED
|
@@ -23,7 +23,13 @@ async function loadMiddleware() {
|
|
|
23
23
|
const filePath = `${Router.middlewarePath}${ext}`
|
|
24
24
|
if (await pathExists(filePath)) {
|
|
25
25
|
const mod = await import(filePath)
|
|
26
|
-
|
|
26
|
+
const loaded = mod.default ?? mod
|
|
27
|
+
if (typeof loaded !== 'object' || loaded === null ||
|
|
28
|
+
(loaded.before !== undefined && typeof loaded.before !== 'function') ||
|
|
29
|
+
(loaded.after !== undefined && typeof loaded.after !== 'function')) {
|
|
30
|
+
throw new Error(`Middleware at '${filePath}' must export an object with optional before/after functions`)
|
|
31
|
+
}
|
|
32
|
+
Router.middleware = loaded as Middleware
|
|
27
33
|
return
|
|
28
34
|
}
|
|
29
35
|
}
|
|
@@ -4,7 +4,15 @@ export default async function(props) {
|
|
|
4
4
|
|
|
5
5
|
// script
|
|
6
6
|
|
|
7
|
-
if(props)
|
|
7
|
+
if(props) {
|
|
8
|
+
const __p__ = typeof props === 'string' ? JSON.parse(decodeURIComponent(props)) : props
|
|
9
|
+
for(const __k__ of Object.keys(__p__)) {
|
|
10
|
+
if(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(__k__)) {
|
|
11
|
+
const __v__ = __p__[__k__]
|
|
12
|
+
eval(`${__k__} = __v__`)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
const compRenders = new Map()
|
|
10
18
|
|
|
@@ -28,24 +36,21 @@ export default async function(props) {
|
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
const ty_invokeEvent = (hash, action) => {
|
|
31
|
-
|
|
32
39
|
if(elemId === ty_generateId(hash, 'ev')) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return action + "('" + event + "')"
|
|
36
|
-
}
|
|
37
|
-
return action
|
|
40
|
+
const toCall = (event && !action.endsWith(')')) ? action + "('" + event + "')" : action
|
|
41
|
+
eval(toCall)
|
|
38
42
|
}
|
|
39
|
-
return
|
|
43
|
+
return ''
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
const ty_assignValue = (hash, variable) => {
|
|
43
|
-
|
|
44
47
|
if(elemId === ty_generateId(hash, 'bind') && event) {
|
|
45
|
-
|
|
48
|
+
if(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(variable)) {
|
|
49
|
+
const __val__ = event.value
|
|
50
|
+
eval(`${variable} = __val__`)
|
|
51
|
+
}
|
|
46
52
|
}
|
|
47
|
-
|
|
48
|
-
return variable
|
|
53
|
+
return ''
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
let elements = '';
|
|
@@ -119,9 +119,9 @@ export default class Yon {
|
|
|
119
119
|
|
|
120
120
|
const formatAttr = (name: string, value: string, hash: string): string => {
|
|
121
121
|
if (name.startsWith('@'))
|
|
122
|
-
return `${name}="\${
|
|
122
|
+
return `${name}="\${ty_invokeEvent('${hash}', '${value}')}"`;
|
|
123
123
|
if (name === ':value')
|
|
124
|
-
return `value="\${
|
|
124
|
+
return `value="\${ty_assignValue('${hash}', '${value}')}"`;
|
|
125
125
|
return `${name}="${value}"`;
|
|
126
126
|
};
|
|
127
127
|
|
|
@@ -266,24 +266,28 @@ export default class Yon {
|
|
|
266
266
|
if (key.startsWith('@')) {
|
|
267
267
|
events.push(`${key}="${value.replace(/(ty_invokeEvent\(')([^"]+)(',[^)]+\))/g, `$1${hash}$3`)}"`);
|
|
268
268
|
} else {
|
|
269
|
-
|
|
269
|
+
// Convert template-literal expr "${foo()}" → foo(), static value → JSON literal
|
|
270
|
+
const expr = value.startsWith('${') && value.endsWith('}')
|
|
271
|
+
? value.slice(2, -1)
|
|
272
|
+
: JSON.stringify(value);
|
|
273
|
+
props.push(`"${key}": ${expr}`);
|
|
270
274
|
}
|
|
271
275
|
}
|
|
272
276
|
|
|
273
277
|
const genId = "${ty_generateId('" + hash + "', 'id')}";
|
|
278
|
+
const propsObj = props.length ? `{${props.join(', ')}}` : 'null';
|
|
274
279
|
|
|
275
280
|
if (isLazy) {
|
|
276
281
|
const filepath = Yon.compMapping.get(component);
|
|
277
|
-
const propsEncoded = props.length ? props.join(';') : '';
|
|
278
282
|
return `
|
|
279
|
-
elements += \`<div id="${genId}" data-lazy-component="${component}" data-lazy-path="/components/${filepath}" data-lazy-props="${
|
|
283
|
+
elements += \`<div id="${genId}" data-lazy-component="${component}" data-lazy-path="/components/${filepath}" data-lazy-props="\${${props.length ? `encodeURIComponent(JSON.stringify(${propsObj}))` : "''"}}\" ${events.join(' ')}></div>\`
|
|
280
284
|
`;
|
|
281
285
|
}
|
|
282
286
|
|
|
283
287
|
return `
|
|
284
288
|
elements += \`<div id="${genId}" ${events.join(' ')}>\`
|
|
285
289
|
if(!compRenders.has('${hash}')) {
|
|
286
|
-
render = await ${component}(
|
|
290
|
+
render = await ${component}(${propsObj})
|
|
287
291
|
elements += await render(elemId, event, '${hash}')
|
|
288
292
|
compRenders.set('${hash}', render)
|
|
289
293
|
} else {
|
|
@@ -200,7 +200,8 @@ const lazyObserver = new IntersectionObserver((entries) => {
|
|
|
200
200
|
async function loadLazyComponent(el: HTMLElement) {
|
|
201
201
|
const path = el.dataset.lazyComponent!;
|
|
202
202
|
const modulePath = el.dataset.lazyPath!;
|
|
203
|
-
const
|
|
203
|
+
const propsRaw = el.dataset.lazyProps || '';
|
|
204
|
+
const props = propsRaw ? JSON.parse(decodeURIComponent(propsRaw)) : null;
|
|
204
205
|
|
|
205
206
|
try {
|
|
206
207
|
const mod = await import(modulePath);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BunRequest, Server } from "bun";
|
|
2
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
3
|
import Router, { RequestContext, RouteOptions, RequestPayload, RouteResponse } from "./route-handler.js";
|
|
3
4
|
import Pool from "./process-pool.js";
|
|
4
5
|
import Validate from "./schema-validator.js";
|
|
@@ -118,11 +119,15 @@ export default class Tach {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
private static isAuthorizedClient(authorization: string | undefined, basicAuth: string): boolean {
|
|
121
|
-
if (authorization)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
if (!authorization) return false
|
|
123
|
+
const [, provided] = authorization.split(' ')
|
|
124
|
+
if (!provided) return false
|
|
125
|
+
const expected = btoa(basicAuth)
|
|
126
|
+
// Timing-safe comparison prevents brute-force timing oracle attacks
|
|
127
|
+
const a = Buffer.from(expected)
|
|
128
|
+
const b = Buffer.from(provided)
|
|
129
|
+
if (a.length !== b.length) return false
|
|
130
|
+
return timingSafeEqual(a, b)
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
private static async serveRequest(
|
|
@@ -187,7 +192,9 @@ export default class Tach {
|
|
|
187
192
|
|
|
188
193
|
/**
|
|
189
194
|
* Decodes a Bearer JWT from an Authorization header.
|
|
190
|
-
*
|
|
195
|
+
* WARNING: The signature is NOT verified. Route handlers must not trust claims
|
|
196
|
+
* without out-of-band verification (e.g. via a middleware that calls a trusted
|
|
197
|
+
* auth service). Rejects tokens whose `exp` claim is in the past.
|
|
191
198
|
* @param authorization - The raw `Authorization` header value
|
|
192
199
|
*/
|
|
193
200
|
private static decodeJWT(authorization: string | undefined) {
|
|
@@ -200,10 +207,19 @@ export default class Tach {
|
|
|
200
207
|
const [header, payload, signature] = token.split('.')
|
|
201
208
|
|
|
202
209
|
try {
|
|
210
|
+
const decodedPayload: Record<string, unknown> = JSON.parse(atob(payload))
|
|
211
|
+
|
|
212
|
+
// Reject expired tokens even without full signature verification
|
|
213
|
+
if (typeof decodedPayload.exp === 'number' && decodedPayload.exp < Math.floor(Date.now() / 1000)) {
|
|
214
|
+
console.warn(`Rejected expired JWT (exp=${decodedPayload.exp})`, process.pid)
|
|
215
|
+
return undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
203
218
|
return {
|
|
204
|
-
header:
|
|
205
|
-
payload:
|
|
219
|
+
header: JSON.parse(atob(header)),
|
|
220
|
+
payload: decodedPayload,
|
|
206
221
|
signature,
|
|
222
|
+
verified: false as const, // Signature NOT verified — do not trust claims without separate verification
|
|
207
223
|
}
|
|
208
224
|
} catch {
|
|
209
225
|
console.warn(`Failed to decode JWT — malformed base64 or JSON payload`, process.pid)
|
|
@@ -263,9 +279,12 @@ export default class Tach {
|
|
|
263
279
|
}
|
|
264
280
|
|
|
265
281
|
} catch (err) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
282
|
+
if (err instanceof Response) {
|
|
283
|
+
res = err
|
|
284
|
+
} else {
|
|
285
|
+
console.error('[process-executor] Unhandled error:', err, process.pid)
|
|
286
|
+
res = Response.json({ error: 'Internal server error' }, { status: 500, headers: Router.headers })
|
|
287
|
+
}
|
|
269
288
|
}
|
|
270
289
|
|
|
271
290
|
console.info(`${path} - ${request!.method} - ${res.status} - ${Date.now() - start}ms`, process.pid)
|
|
@@ -3,6 +3,11 @@ import Router from "./route-handler.js"
|
|
|
3
3
|
/** A Bun subprocess with all three stdio channels opened as pipes. */
|
|
4
4
|
export type PipedProcess = ReturnType<typeof Bun.spawn<"pipe", "pipe", "pipe">>
|
|
5
5
|
|
|
6
|
+
/** Maximum time (ms) a handler process may run before it is killed. Default: 30 s. */
|
|
7
|
+
const HANDLER_TIMEOUT_MS = process.env.HANDLER_TIMEOUT_MS
|
|
8
|
+
? Number(process.env.HANDLER_TIMEOUT_MS)
|
|
9
|
+
: 30_000
|
|
10
|
+
|
|
6
11
|
export default class Pool {
|
|
7
12
|
|
|
8
13
|
/**
|
|
@@ -58,6 +63,9 @@ export default class Pool {
|
|
|
58
63
|
* Returns the pre-warmed process for `handler` if one exists and is still
|
|
59
64
|
* running, otherwise spawns a fresh process. Immediately schedules a
|
|
60
65
|
* replacement warm process for the next request.
|
|
66
|
+
*
|
|
67
|
+
* A kill-on-timeout timer is armed: if the process has not exited within
|
|
68
|
+
* HANDLER_TIMEOUT_MS it is killed and the event is logged.
|
|
61
69
|
*/
|
|
62
70
|
static acquireHandler(handler: string): PipedProcess {
|
|
63
71
|
const warmed = Pool.warmedProcesses.get(handler)
|
|
@@ -67,14 +75,27 @@ export default class Pool {
|
|
|
67
75
|
setImmediate(() => Pool.prewarmHandler(handler))
|
|
68
76
|
|
|
69
77
|
// If the warmed process exited early (e.g. handler syntax error), spawn fresh
|
|
70
|
-
|
|
78
|
+
const proc: PipedProcess = (warmed && warmed.exitCode === null)
|
|
79
|
+
? warmed
|
|
80
|
+
: Bun.spawn<"pipe", "pipe", "pipe">({
|
|
81
|
+
cmd: [handler],
|
|
82
|
+
stdin: "pipe",
|
|
83
|
+
stdout: "pipe",
|
|
84
|
+
stderr: "pipe",
|
|
85
|
+
env: process.env,
|
|
86
|
+
})
|
|
71
87
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
})
|
|
88
|
+
// Kill hung processes to prevent resource exhaustion
|
|
89
|
+
const timeout = setTimeout(() => {
|
|
90
|
+
if (proc.exitCode === null) {
|
|
91
|
+
console.error(`[pool] Handler timed out after ${HANDLER_TIMEOUT_MS}ms — killing process`, proc.pid)
|
|
92
|
+
try { proc.kill() } catch { /* already exited */ }
|
|
93
|
+
}
|
|
94
|
+
}, HANDLER_TIMEOUT_MS)
|
|
95
|
+
|
|
96
|
+
// Clear the timer once the process exits naturally
|
|
97
|
+
proc.exited.then(() => clearTimeout(timeout)).catch(() => clearTimeout(timeout))
|
|
98
|
+
|
|
99
|
+
return proc
|
|
79
100
|
}
|
|
80
101
|
}
|
|
@@ -7,6 +7,8 @@ export interface RequestContext {
|
|
|
7
7
|
header: Record<string, unknown>
|
|
8
8
|
payload: Record<string, unknown>
|
|
9
9
|
signature: string
|
|
10
|
+
/** Always false — signature is decoded but NOT cryptographically verified */
|
|
11
|
+
verified: false
|
|
10
12
|
}
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -64,7 +66,13 @@ export default class Router {
|
|
|
64
66
|
"Access-Control-Allow-Credential": process.env.ALLOW_CREDENTIALS || "false",
|
|
65
67
|
"Access-Control-Expose-Headers": process.env.ALLOW_EXPOSE_HEADERS || "",
|
|
66
68
|
"Access-Control-Max-Age": process.env.ALLOW_MAX_AGE || "",
|
|
67
|
-
"Access-Control-Allow-Methods": process.env.ALLOW_METHODS || ""
|
|
69
|
+
"Access-Control-Allow-Methods": process.env.ALLOW_METHODS || "",
|
|
70
|
+
// Security headers
|
|
71
|
+
"X-Frame-Options": "DENY",
|
|
72
|
+
"X-Content-Type-Options": "nosniff",
|
|
73
|
+
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
|
74
|
+
"Content-Security-Policy": process.env.CONTENT_SECURITY_POLICY || "default-src 'self'",
|
|
75
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
/**
|
|
@@ -153,17 +161,24 @@ export default class Router {
|
|
|
153
161
|
return { handler: `${Router.routesPath}${route}/${request.method}`, stdin, config: requestConfig }
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
/** Maximum allowed length for any single route or query parameter value. */
|
|
165
|
+
static readonly MAX_PARAM_LENGTH = Number(process.env.MAX_PARAM_LENGTH) || 1000
|
|
166
|
+
|
|
156
167
|
/**
|
|
157
168
|
* Coerces an array of string path segments into their native types
|
|
158
169
|
* (number, boolean, null, undefined, or string).
|
|
159
170
|
* @param input - Raw string segments from the URL path
|
|
160
171
|
* @returns Typed parameter values
|
|
172
|
+
* @throws Response with status 400 if any segment exceeds MAX_PARAM_LENGTH
|
|
161
173
|
*/
|
|
162
174
|
static parseParams(input: string[]): (string | boolean | number | null | undefined)[] {
|
|
163
175
|
|
|
164
176
|
const params: (string | boolean | number | null | undefined)[] = []
|
|
165
177
|
|
|
166
178
|
for (const param of input) {
|
|
179
|
+
if (param.length > Router.MAX_PARAM_LENGTH) {
|
|
180
|
+
throw Response.json({ error: 'Parameter too long' }, { status: 400 })
|
|
181
|
+
}
|
|
167
182
|
|
|
168
183
|
const num = Number(param)
|
|
169
184
|
|
|
@@ -190,6 +205,10 @@ export default class Router {
|
|
|
190
205
|
|
|
191
206
|
for (const [key, val] of input) {
|
|
192
207
|
|
|
208
|
+
if (typeof val === "string" && val.length > Router.MAX_PARAM_LENGTH) {
|
|
209
|
+
throw Response.json({ error: 'Parameter too long' }, { status: 400 })
|
|
210
|
+
}
|
|
211
|
+
|
|
193
212
|
if (typeof val === "string") {
|
|
194
213
|
|
|
195
214
|
try {
|