@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.4.2",
3
+ "version": "2.4.4",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -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 an alphanumeric, which is what rules out `..`
46
- * and an empty segment (`//`) without a second pass over the string, and the
47
- * extension list keeps this to font payloads.
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-9][a-zA-Z0-9._-]*(?:\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*\.(?:woff2?|ttf|otf|eot|svg)$/
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
- const cacheExt = `${fs.parse(gstaticPath).ext || '.bin'}`
202
- const cacheName = `${toHash(gstaticPath)}${cacheExt}`
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
- return built(rawFile)
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
  /**
@@ -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.origin) {
58
+ if (origin && origin !== 'null' && origin !== requestOrigin(req, url)) {
31
59
  return `cross-origin request rejected (Origin: ${origin})`
32
60
  }
33
61
 
@@ -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 finish() {
127
- if (closed) return
128
- closed = true
129
- if (keepAliveTimer) clearInterval(keepAliveTimer)
133
+ function runCleanup() {
134
+ const pending = cleanup
135
+ cleanup = undefined
130
136
  try {
131
- cleanup?.()
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.