@bakery-framework/core 2.4.2 → 2.4.4
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 +1 -1
- package/src/client/utils.ts +5 -1
- package/src/handlers/assets/google-font.ts +54 -10
- package/src/utils/fs.ts +4 -1
- package/src/utils/http/csrf.ts +29 -1
- package/src/utils/http/sse.ts +21 -5
package/package.json
CHANGED
package/src/client/utils.ts
CHANGED
|
@@ -333,9 +333,13 @@ if (typeof document !== 'undefined') {
|
|
|
333
333
|
// A `source: 'document'` rule with href_matches '/*' and eager eagerness
|
|
334
334
|
// ignored the exclusions above and fully loaded (and ran the JS of) every
|
|
335
335
|
// same-origin link, including things like /logout or a destructive GET.
|
|
336
|
+
//
|
|
337
|
+
// Prefetch waits for intent too. With no eagerness a list rule fetches at
|
|
338
|
+
// once, so a sidebar of 32 links asked for all 32 pages at every load (1.5 s
|
|
339
|
+
// and 6 MB on one app); moderate asked for none at load and one on a hover.
|
|
336
340
|
const urlList = Array.from(urls)
|
|
337
341
|
specScript.textContent = JSON.stringify({
|
|
338
|
-
prefetch: [{ source: 'list', urls: urlList }],
|
|
342
|
+
prefetch: [{ source: 'list', urls: urlList, eagerness: 'moderate' }],
|
|
339
343
|
prerender: [{ source: 'list', urls: urlList, eagerness: 'moderate' }],
|
|
340
344
|
})
|
|
341
345
|
|
|
@@ -42,12 +42,16 @@ const GF_MAX_GSTATIC_PATH = 256
|
|
|
42
42
|
* A path under `fonts.gstatic.com`, as the rewritten CSS references it:
|
|
43
43
|
* `s/roboto/v47/KFOmCnqEu92Fr1Mu4mxK.woff2`.
|
|
44
44
|
*
|
|
45
|
-
* Every segment must start with
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* Every segment must start with something other than a dot, which is what
|
|
46
|
+
* rules out `..`, `.` and a dot-file, and must hold at least that one
|
|
47
|
+
* character, which rules out an empty segment (`//`), all without a second
|
|
48
|
+
* pass over the string. The first character may be `_` or `-` because file
|
|
49
|
+
* names are base64url: Figtree v9's are `_Xms-HUzqDCFdgfMm4q9DbZs.woff2` and
|
|
50
|
+
* `_Xms-HUzqDCFdgfMm4S9DQ.woff2`, and an alphanumeric-only start answered both
|
|
51
|
+
* with a 404. The extension list keeps this to font payloads.
|
|
48
52
|
*/
|
|
49
53
|
const GF_GSTATIC_PATH =
|
|
50
|
-
/^[a-zA-Z0-
|
|
54
|
+
/^[a-zA-Z0-9_-][a-zA-Z0-9._-]*(?:\/[a-zA-Z0-9_-][a-zA-Z0-9._-]*)*\.(?:woff2?|ttf|otf|eot|svg)$/
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
57
|
* How many distinct upstream assets this handler will keep on disk.
|
|
@@ -123,6 +127,39 @@ function canonicalQuery(search: string): string | null {
|
|
|
123
127
|
return `?${pairs.sort().join('&')}`
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
/**
|
|
131
|
+
* A `&text=` subset's address, as the CSS endpoint writes it:
|
|
132
|
+
* `l/font?kit=neILzCirqoswsqX9_oC0MuFXPPo&skey=7ed805a363b4659c&v=v32`.
|
|
133
|
+
*
|
|
134
|
+
* It has no extension and names its font in the query alone, so it cannot pass
|
|
135
|
+
* `GF_GSTATIC_PATH`, and every such request answered 404. The three parameters
|
|
136
|
+
* are the only ones allowed through, in a canonical order, and a kit is free
|
|
137
|
+
* text in the way a family name is: `GF_MAX_ENTRIES` is what bounds them.
|
|
138
|
+
*/
|
|
139
|
+
const GF_SUBSET_PATH = 'l/font'
|
|
140
|
+
const GF_SUBSET_PARAMS = new Set(['kit', 'skey', 'v'])
|
|
141
|
+
const GF_SUBSET_VALUE = /^[a-zA-Z0-9_-]+$/
|
|
142
|
+
|
|
143
|
+
/** The subset's query in canonical order, or `null` if it is not one. */
|
|
144
|
+
function subsetQuery(search: string): string | null {
|
|
145
|
+
const raw = search.startsWith('?') ? search.slice(1) : search
|
|
146
|
+
if (!raw || raw.length > GF_MAX_GSTATIC_PATH) return null
|
|
147
|
+
|
|
148
|
+
const pairs = raw.split('&')
|
|
149
|
+
const names = new Set<string>()
|
|
150
|
+
for (const pair of pairs) {
|
|
151
|
+
const [name, value, extra] = pair.split('=')
|
|
152
|
+
if (!GF_SUBSET_PARAMS.has(name) || names.has(name)) return null
|
|
153
|
+
if (extra !== undefined || !value || !GF_SUBSET_VALUE.test(value)) {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
names.add(name)
|
|
157
|
+
}
|
|
158
|
+
if (!names.has('kit')) return null
|
|
159
|
+
|
|
160
|
+
return `?${pairs.sort().join('&')}`
|
|
161
|
+
}
|
|
162
|
+
|
|
126
163
|
export class GoogleFontHandler extends Handler {
|
|
127
164
|
static get cacheDir() {
|
|
128
165
|
return fs.resolve(Bakery.cacheDir, 'gf_cache')
|
|
@@ -134,7 +171,7 @@ export class GoogleFontHandler extends Handler {
|
|
|
134
171
|
|
|
135
172
|
static async handle(path: string, req: Request) {
|
|
136
173
|
if (path.startsWith('/_gf/gstatic/')) {
|
|
137
|
-
return this.handleGstatic(path)
|
|
174
|
+
return this.handleGstatic(path, new URL(req.url).search)
|
|
138
175
|
}
|
|
139
176
|
|
|
140
177
|
const rawPath = path.slice('/_gf'.length)
|
|
@@ -185,28 +222,35 @@ export class GoogleFontHandler extends Handler {
|
|
|
185
222
|
return cached
|
|
186
223
|
}
|
|
187
224
|
|
|
188
|
-
private static async handleGstatic(path: string) {
|
|
225
|
+
private static async handleGstatic(path: string, search: string) {
|
|
189
226
|
const gstaticPath = path.slice('/_gf/gstatic/'.length)
|
|
227
|
+
const isSubset = gstaticPath === GF_SUBSET_PATH
|
|
228
|
+
const query = isSubset ? subsetQuery(search) : ''
|
|
190
229
|
|
|
191
230
|
// Refused before `this.cacheDir` is read, so a rejection costs neither a
|
|
192
231
|
// config lookup nor a path resolution.
|
|
193
232
|
if (
|
|
233
|
+
query === null ||
|
|
194
234
|
gstaticPath.length > GF_MAX_GSTATIC_PATH ||
|
|
195
|
-
!GF_GSTATIC_PATH.test(gstaticPath)
|
|
235
|
+
(!isSubset && !GF_GSTATIC_PATH.test(gstaticPath))
|
|
196
236
|
) {
|
|
197
237
|
return response.error('Unknown Google Fonts asset', 404)
|
|
198
238
|
}
|
|
199
239
|
|
|
200
240
|
const cacheDir = fs.resolve(this.cacheDir, 'gstatic')
|
|
201
|
-
|
|
202
|
-
|
|
241
|
+
// The CSS endpoint asks as Chrome, so a subset is always a woff2. It has no
|
|
242
|
+
// extension of its own to read that from.
|
|
243
|
+
const cacheExt = isSubset
|
|
244
|
+
? '.woff2'
|
|
245
|
+
: `${fs.parse(gstaticPath).ext || '.bin'}`
|
|
246
|
+
const cacheName = `${toHash(`${gstaticPath}${query}`)}${cacheExt}`
|
|
203
247
|
|
|
204
248
|
const cached = await fs.getOrCreateCachedFile(
|
|
205
249
|
cacheDir,
|
|
206
250
|
cacheName,
|
|
207
251
|
null,
|
|
208
252
|
async () => {
|
|
209
|
-
const gfUrl = `https://fonts.gstatic.com/${gstaticPath}`
|
|
253
|
+
const gfUrl = `https://fonts.gstatic.com/${gstaticPath}${query}`
|
|
210
254
|
const res = await fetch(gfUrl)
|
|
211
255
|
|
|
212
256
|
if (!res.ok) return null
|
package/src/utils/fs.ts
CHANGED
|
@@ -781,7 +781,10 @@ export namespace FileSystem {
|
|
|
781
781
|
if (content == null) return null
|
|
782
782
|
await mkdir(cacheDir)
|
|
783
783
|
await rawFile.write(content)
|
|
784
|
-
|
|
784
|
+
// A new handle: `rawFile` read its size before the file existed, so serving
|
|
785
|
+
// it answered 200 with `content-length: 0` and no body. Measured on a cold
|
|
786
|
+
// font, which Chrome then reports as failed; the next request read the file.
|
|
787
|
+
return built(Bun.file(rawPath))
|
|
785
788
|
}
|
|
786
789
|
|
|
787
790
|
/**
|
package/src/utils/http/csrf.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* arrive with the victim's session attached.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { Bakery } from '../../core/bakery'
|
|
10
11
|
import { Try } from '../isomorphic/try'
|
|
11
12
|
|
|
12
13
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
@@ -14,20 +15,47 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
|
14
15
|
/** `Sec-Fetch-Site` values that mean the request did not come from another site. */
|
|
15
16
|
const SAME_SITE_VALUES = new Set(['same-origin', 'same-site', 'none'])
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The origin a browser sees for this request.
|
|
20
|
+
*
|
|
21
|
+
* A proxy that ends TLS leaves the request looking like `http://app.example`
|
|
22
|
+
* while the browser reports `https://app.example`. Under `trustProxy` the
|
|
23
|
+
* proxy's forwarded scheme and host are believed, as `getHostname` and the
|
|
24
|
+
* session's `Secure` flag believe them. Without it the request's own URL
|
|
25
|
+
* decides, since any client could send those headers.
|
|
26
|
+
*/
|
|
27
|
+
export function requestOrigin(req: Request, url: URL): string {
|
|
28
|
+
if (!Bakery.config.trustProxy) return url.origin
|
|
29
|
+
|
|
30
|
+
const forwarded = (name: string) =>
|
|
31
|
+
req.headers.get(name)?.split(',')[0].trim()
|
|
32
|
+
const scheme = forwarded('x-forwarded-proto') || url.protocol.slice(0, -1)
|
|
33
|
+
const host = forwarded('x-forwarded-host') || url.host
|
|
34
|
+
|
|
35
|
+
return Try(() => new URL(`${scheme}://${host}`).origin) ?? url.origin
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
/**
|
|
18
39
|
* Returns a reason string when the request looks cross-site, or `null` when it
|
|
19
40
|
* is safe to process. Requests with neither `Origin` nor `Sec-Fetch-Site` (curl,
|
|
20
41
|
* server-to-server, older clients) are allowed: browsers always send at least
|
|
21
42
|
* one of them on a cross-origin request.
|
|
43
|
+
*
|
|
44
|
+
* A same-origin `Sec-Fetch-Site` settles it before `Origin` is read. A browser
|
|
45
|
+
* sets that header and page script cannot, and it is right behind a proxy that
|
|
46
|
+
* ends TLS, where `Origin` names a scheme this server never saw: measured, every
|
|
47
|
+
* browser write there answered 403, with `trustProxy` on or off. A client that
|
|
48
|
+
* forges the header could forge `Origin` as well, so nothing is lost.
|
|
22
49
|
*/
|
|
23
50
|
export function checkSameOrigin(req: Request, url: URL): string | null {
|
|
24
51
|
const fetchSite = req.headers.get('sec-fetch-site')
|
|
25
52
|
if (fetchSite && !SAME_SITE_VALUES.has(fetchSite)) {
|
|
26
53
|
return `cross-site request rejected (Sec-Fetch-Site: ${fetchSite})`
|
|
27
54
|
}
|
|
55
|
+
if (fetchSite === 'same-origin' || fetchSite === 'none') return null
|
|
28
56
|
|
|
29
57
|
const origin = req.headers.get('origin')
|
|
30
|
-
if (origin && origin !== 'null' && origin !== url
|
|
58
|
+
if (origin && origin !== 'null' && origin !== requestOrigin(req, url)) {
|
|
31
59
|
return `cross-origin request rejected (Origin: ${origin})`
|
|
32
60
|
}
|
|
33
61
|
|
package/src/utils/http/sse.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* write after the client has gone throws where nobody is catching.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { Bakery } from '../../core/bakery'
|
|
17
|
+
|
|
16
18
|
/** One event. Every field is optional except the payload. */
|
|
17
19
|
export interface SSEMessage {
|
|
18
20
|
/** Serialized with `JSON.stringify` unless it is already a string. */
|
|
@@ -105,6 +107,11 @@ export function sse(
|
|
|
105
107
|
const encoder = new TextEncoder()
|
|
106
108
|
const { keepAlive = 15_000, retry } = options
|
|
107
109
|
|
|
110
|
+
// A quiet stream is not a finished one, and `Bun.serve` closes a connection
|
|
111
|
+
// that stays quiet past its idle timeout. Lifted for this request only: the
|
|
112
|
+
// stream ends when the client leaves or `close()` runs.
|
|
113
|
+
Bakery.server?.timeout(req, 0)
|
|
114
|
+
|
|
108
115
|
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
109
116
|
let closed = false
|
|
110
117
|
let cleanup: (() => void) | void
|
|
@@ -123,16 +130,22 @@ export function sse(
|
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
132
|
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (keepAliveTimer) clearInterval(keepAliveTimer)
|
|
133
|
+
function runCleanup() {
|
|
134
|
+
const pending = cleanup
|
|
135
|
+
cleanup = undefined
|
|
130
136
|
try {
|
|
131
|
-
|
|
137
|
+
pending?.()
|
|
132
138
|
} catch {
|
|
133
139
|
// A producer's cleanup that throws must not prevent the stream closing;
|
|
134
140
|
// the connection is already going away either way.
|
|
135
141
|
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function finish() {
|
|
145
|
+
if (closed) return
|
|
146
|
+
closed = true
|
|
147
|
+
if (keepAliveTimer) clearInterval(keepAliveTimer)
|
|
148
|
+
runCleanup()
|
|
136
149
|
try {
|
|
137
150
|
controller?.close()
|
|
138
151
|
} catch {
|
|
@@ -176,6 +189,9 @@ export function sse(
|
|
|
176
189
|
|
|
177
190
|
try {
|
|
178
191
|
cleanup = await producer(stream)
|
|
192
|
+
// The client can leave during that await. `finish` has then run, found
|
|
193
|
+
// no cleanup and will not run again, so this one would never be called.
|
|
194
|
+
if (closed) runCleanup()
|
|
179
195
|
} catch {
|
|
180
196
|
// A producer that throws ends the stream rather than leaving the client
|
|
181
197
|
// hanging on a connection nobody will write to again.
|