@bakery-framework/core 2.0.0-alpha.3 → 2.0.0-alpha.5
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/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import { Bakery } from '../../core/bakery'
|
|
|
2
2
|
import { handlerLog } from '../../logger/serve-log'
|
|
3
3
|
import { FileSystem } from '../../utils/fs'
|
|
4
4
|
import { checkCsrf, response } from '../../utils/http'
|
|
5
|
+
import { parsedUrl } from '../../utils/http/url'
|
|
5
6
|
import type { Handler } from '../core/$base'
|
|
6
7
|
import { bustInDev, DynamicHandler } from '../core/$dynamic'
|
|
7
8
|
import { ErrorHandler } from '../core/$error'
|
|
@@ -29,8 +30,7 @@ export class ApiHandler extends DynamicHandler {
|
|
|
29
30
|
static async handle(path: string, req: Request) {
|
|
30
31
|
// State-changing methods must be same-origin. SameSite=Lax alone does not
|
|
31
32
|
// cover this: a cross-site form POST is a CORS-simple request.
|
|
32
|
-
const
|
|
33
|
-
const csrf = checkCsrf(req, url)
|
|
33
|
+
const csrf = checkCsrf(req, parsedUrl(req))
|
|
34
34
|
if (csrf) return response.json.error(403, csrf) as unknown as Response
|
|
35
35
|
|
|
36
36
|
const info = await this.resolveRoute(path)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Bakery } from '../../core/bakery'
|
|
2
2
|
import { handlerLog } from '../../logger'
|
|
3
3
|
import { response } from '../../utils/http'
|
|
4
|
+
import { parsedUrl } from '../../utils/http/url'
|
|
4
5
|
import { Handler } from '../core/$base'
|
|
5
6
|
|
|
6
7
|
export class ProxyHandler extends Handler {
|
|
@@ -30,7 +31,7 @@ export class ProxyHandler extends Handler {
|
|
|
30
31
|
baseTarget +
|
|
31
32
|
(trailingPath.startsWith('/') ? '' : '/') +
|
|
32
33
|
trailingPath +
|
|
33
|
-
(
|
|
34
|
+
parsedUrl(req).search
|
|
34
35
|
break
|
|
35
36
|
}
|
|
36
37
|
|
package/src/router.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
withStatus,
|
|
20
20
|
} from './utils/http'
|
|
21
21
|
import { applyCors, preflightResponse } from './utils/http/cors'
|
|
22
|
+
import { parsedUrl } from './utils/http/url'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Resolve a WebSocket upgrade, refusing cross-origin handshakes first.
|
|
@@ -39,7 +40,7 @@ export async function upgradeWebsocket(
|
|
|
39
40
|
req: Request,
|
|
40
41
|
path: string,
|
|
41
42
|
): Promise<boolean | undefined> {
|
|
42
|
-
const url
|
|
43
|
+
const url = parsedUrl(req)
|
|
43
44
|
const denied = checkWebSocketOrigin(req, url)
|
|
44
45
|
if (denied) {
|
|
45
46
|
serveLog.WEBSOCKET_ERR({
|
|
@@ -85,10 +86,9 @@ export function handleRequest(
|
|
|
85
86
|
req: Request,
|
|
86
87
|
): Handler.Response | MixedPromise<symbol>
|
|
87
88
|
export async function handleRequest(req: Request) {
|
|
88
|
-
// worker.ts
|
|
89
|
-
// direct
|
|
90
|
-
const url
|
|
91
|
-
;(req as any).__parsedUrl = url
|
|
89
|
+
// Shared with worker.ts, which has already asked for the same parse; a
|
|
90
|
+
// direct caller (test, embedder) gets it parsed here instead.
|
|
91
|
+
const url = parsedUrl(req)
|
|
92
92
|
const path = url.pathname
|
|
93
93
|
|
|
94
94
|
// One read of the config getter, not three: `Bakery.serveRoot` walks
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request-predicate half of plugin authorization.
|
|
3
|
+
*
|
|
4
|
+
* One implementation, deliberately in core, for the same reason
|
|
5
|
+
* `credential.ts` is here: the dashboard, db-explorer and analytics plugins
|
|
6
|
+
* each need this guard, and per-plugin copies of security code drift. These
|
|
7
|
+
* three had already drifted — one coerced its predicate's return with
|
|
8
|
+
* `Boolean`, one gated on `!DEV` where the others read `PROD`, one swallowed a
|
|
9
|
+
* `getClientIp` throw into an empty string and carried on. Each divergence is
|
|
10
|
+
* resolved below toward the fail-closed reading, and each is marked as a
|
|
11
|
+
* decision rather than left to look like a style choice.
|
|
12
|
+
*
|
|
13
|
+
* It owns *only* the predicate — no logins, no sessions, no backoff. The shared
|
|
14
|
+
* key path is `credential.ts`; a plugin that offers both doors composes them.
|
|
15
|
+
*
|
|
16
|
+
* `getClientIp` is imported from `./ip` directly, never through this
|
|
17
|
+
* directory's barrel or `@bakery-framework/core`: a core module that reaches for a
|
|
18
|
+
* barrel closes an import cycle, which is how 67 tests once failed with
|
|
19
|
+
* `ReferenceError: Cannot access 'Logger' before initialization`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { getClientIp } from './ip'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A host application's access predicate. Returning `true` admits; returning
|
|
26
|
+
* anything else, or throwing, denies (convention 2).
|
|
27
|
+
*
|
|
28
|
+
* The framework authenticates nobody. The application, which already knows who
|
|
29
|
+
* its users are, supplies this.
|
|
30
|
+
*/
|
|
31
|
+
export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Addresses only, never hostnames. Module-private: neither plugin copy
|
|
35
|
+
* exported it, and the membership test is the whole of the useful surface.
|
|
36
|
+
*
|
|
37
|
+
* `'localhost'` used to be a member, back when the request's *hostname* was
|
|
38
|
+
* compared against this set as well. A peer address is never the string
|
|
39
|
+
* `localhost`, and accepting it meant `X-Forwarded-For: localhost` counted as
|
|
40
|
+
* loopback under `trustProxy` — and, worse, that `new URL(req.url).hostname`
|
|
41
|
+
* did too. Bun builds that from the client's own `Host` header and
|
|
42
|
+
* `DEFAULT_HOST` is 0.0.0.0, so any peer on the LAN could send
|
|
43
|
+
* `Host: localhost` and be handed a database browser.
|
|
44
|
+
*/
|
|
45
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
46
|
+
|
|
47
|
+
/** True when the request came from this machine. */
|
|
48
|
+
export function isLoopback(req: Request): boolean {
|
|
49
|
+
// The peer address is the only evidence here the client does not choose.
|
|
50
|
+
//
|
|
51
|
+
// DECISION (divergence 3): a throw returns `false` immediately rather than
|
|
52
|
+
// falling through with `ip = ''`. `getClientIp` reads config and the live
|
|
53
|
+
// server, either of which may be absent (tests, early boot). The two spell
|
|
54
|
+
// the same answer today, because `LOOPBACK.has('')` is false — but only by
|
|
55
|
+
// coincidence, and the fall-through invites a later edit that adds a second
|
|
56
|
+
// source of evidence below this line and silently consults it on the
|
|
57
|
+
// indeterminate path. Returning here says the answer is settled: no address
|
|
58
|
+
// means no evidence, and per convention 2 an indeterminate answer is a
|
|
59
|
+
// denial, not a reason to ask something the requester controls.
|
|
60
|
+
try {
|
|
61
|
+
return LOOPBACK.has(getClientIp(req))
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The default when an application configures no predicate: loopback in
|
|
69
|
+
* development, nobody in production. Forgetting to configure a plugin
|
|
70
|
+
* therefore cannot expose it to the internet.
|
|
71
|
+
*/
|
|
72
|
+
export function defaultAuthorize(req: Request): boolean {
|
|
73
|
+
// DECISION (divergence 2): gate on `PROD`, not `!DEV`, and read it at call
|
|
74
|
+
// time — the mode flags are accessors on `process.env` (`core/init.ts`), so
|
|
75
|
+
// they are process state and tests flip them.
|
|
76
|
+
//
|
|
77
|
+
// The two spellings look interchangeable because `init.ts` derives them as
|
|
78
|
+
// complements (`PROD = !isDev && !--dev-worker`). They are not, because they
|
|
79
|
+
// are *independently settable* accessors and the test fixtures set them one
|
|
80
|
+
// at a time: `asDev` flips `DEV` alone and leaves the ambient `PROD` — which
|
|
81
|
+
// under `bun test` is `true` — in place. In that state `!DEV` admits a
|
|
82
|
+
// loopback caller and `PROD` denies, so `PROD` is both the fail-closed
|
|
83
|
+
// reading and the one that literally names the condition it means, rather
|
|
84
|
+
// than inferring production from the absence of development.
|
|
85
|
+
//
|
|
86
|
+
// Hence `!== false` rather than a plain truthiness test, which is the one
|
|
87
|
+
// place a bare `PROD` gate would be *weaker* than `!DEV`. The flag does not
|
|
88
|
+
// exist until `core/init.ts` has run, and `if (undefined)` falls straight
|
|
89
|
+
// through to the loopback check — so an uninitialised process would open the
|
|
90
|
+
// door that an initialised production one keeps shut. `init.ts` defines
|
|
91
|
+
// `PROD` as a real boolean (`accessor(!isDev && !hasDevWorkerArg)`), so
|
|
92
|
+
// "explicitly false" is a statement only a booted development server can
|
|
93
|
+
// make. Unset is not evidence of development; it is no evidence at all.
|
|
94
|
+
//
|
|
95
|
+
// `isLoopback` would very likely deny on its own there, having no config or
|
|
96
|
+
// server to read an address from. That is a second line of defence, not this
|
|
97
|
+
// one's excuse: a guard should not depend on another guard's failure mode.
|
|
98
|
+
if (import.meta.env.PROD !== false) return false
|
|
99
|
+
return isLoopback(req)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The configured predicate, or the fail-closed default. */
|
|
103
|
+
export function resolveAuthorize(fn?: AuthorizeFn): AuthorizeFn {
|
|
104
|
+
return fn ?? defaultAuthorize
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run a predicate without letting a broken one grant access. Guard semantics
|
|
109
|
+
* per convention 2: the *authorizer* may throw or answer nonsense, and the
|
|
110
|
+
* answer to any indeterminate state is denial.
|
|
111
|
+
*/
|
|
112
|
+
export async function isAuthorized(
|
|
113
|
+
authorize: AuthorizeFn,
|
|
114
|
+
req: Request,
|
|
115
|
+
): Promise<boolean> {
|
|
116
|
+
try {
|
|
117
|
+
// DECISION (divergence 1): `=== true`, never `Boolean(...)`. The predicate
|
|
118
|
+
// comes from application code and `AuthorizeFn`'s return type is only
|
|
119
|
+
// advice — an untyped, transpiled or `as any` predicate can hand back
|
|
120
|
+
// anything. `Boolean` admits every truthy non-boolean, so a check that
|
|
121
|
+
// answers with a status string denies on `""` and *grants* on `"no"`, and
|
|
122
|
+
// one that answers with a count grants on any non-zero. Admission is the
|
|
123
|
+
// expensive direction to get wrong; require the exact affirmative.
|
|
124
|
+
return (await authorize(req)) === true
|
|
125
|
+
} catch {
|
|
126
|
+
// A predicate that throws is indeterminate, and indeterminate is denied.
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/utils/http/body.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MapOf } from '../../types'
|
|
2
|
+
import { parsedUrl } from './url'
|
|
2
3
|
|
|
3
4
|
export async function processBody(req: Request): Promise<MapOf<any>> {
|
|
4
5
|
const getParsedBody = async (): Promise<MapOf<any>> => {
|
|
@@ -18,8 +19,7 @@ export async function processBody(req: Request): Promise<MapOf<any>> {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function getBodyFromURI(req: Request): MapOf<any> {
|
|
21
|
-
const
|
|
22
|
-
const searchParams = url.searchParams
|
|
22
|
+
const searchParams = parsedUrl(req).searchParams
|
|
23
23
|
return Object.fromEntries(searchParams.entries())
|
|
24
24
|
}
|
|
25
25
|
|
package/src/utils/http/index.ts
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request's parsed URL, parsed at most once.
|
|
3
|
+
*
|
|
4
|
+
* Why memoize at all: `new URL` measures ~1.7µs, and the router, the body
|
|
5
|
+
* parser and the proxy handler all want the same parse of the same request —
|
|
6
|
+
* so the parse was hoisted into the server's `fetch` in `packages/cli/src/worker.ts`
|
|
7
|
+
* and shared.
|
|
8
|
+
*
|
|
9
|
+
* Why a `WeakMap` rather than the property it replaces. The memo used to be
|
|
10
|
+
* `(req as any).__parsedUrl`: two writers, six readers across three packages,
|
|
11
|
+
* every reader spelled `(req as any).__parsedUrl || new URL(req.url)` so that
|
|
12
|
+
* it silently re-parsed whenever the writer had not run first. That is a
|
|
13
|
+
* writer/reader ordering hazard with no way to observe it going wrong — a
|
|
14
|
+
* missed write costs microseconds, not correctness, so nothing ever fails.
|
|
15
|
+
* One function collapses both roles: there is no ordering left to get wrong,
|
|
16
|
+
* no `any` cast anywhere, and no writer to forget. The map also keeps the
|
|
17
|
+
* framework from mutating a `Request` object it does not own — a caller's
|
|
18
|
+
* `Request` goes in and comes back unchanged — and holds its keys weakly, so
|
|
19
|
+
* an entry dies with the request rather than needing eviction (convention 6).
|
|
20
|
+
*
|
|
21
|
+
* There is deliberately no setter. Nothing outside this module needs to say
|
|
22
|
+
* what a request's URL is; `req.url` already does.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const cache = new WeakMap<Request, URL>()
|
|
26
|
+
|
|
27
|
+
/** The parsed `req.url`, cached per request. */
|
|
28
|
+
export function parsedUrl(req: Request): URL {
|
|
29
|
+
const cached = cache.get(req)
|
|
30
|
+
if (cached) return cached
|
|
31
|
+
|
|
32
|
+
const url = new URL(req.url)
|
|
33
|
+
cache.set(req, url)
|
|
34
|
+
return url
|
|
35
|
+
}
|