@astrale-os/kernel-server 0.5.0-beta.70 → 0.5.0-beta.71

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.
@@ -139,6 +139,15 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
139
139
  const invocation = snapshot.kind === 'release'
140
140
  ? routes.decode(route, native, snapshot.domain, snapshot.references)
141
141
  : routes.legacy.decode(route, native, snapshot.domain, snapshot.table.publication);
142
+ if (invocation.credentialIn === 'cookie' &&
143
+ isStateChanging(request) &&
144
+ isClearlyCrossSite(request, externalOrigin ?? new URL(request.url).origin)) {
145
+ const refusal = {
146
+ code: errors.CODES.ACCESS_DENIED,
147
+ message: 'Cross-site cookie-authenticated request to a native route is refused.',
148
+ };
149
+ return nativeError(refusal, errors.httpStatusOf(refusal), corsFor);
150
+ }
142
151
  const idempotencyHeader = request.headers.get(protocol.HTTP_IDEMPOTENCY_KEY);
143
152
  const idempotencyKey = idempotencyHeader === null
144
153
  ? undefined
@@ -160,8 +169,12 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
160
169
  }
161
170
  if (result.kind === 'binary') {
162
171
  if (isStreamingBinary(result.value)) {
172
+ // Hand the operation off only once the streaming response is built. A refused render
173
+ // (reserved header or a body-forbidding status) throws before this point, so the
174
+ // `finally` below still settles the operation instead of leaking its slot.
175
+ const streaming = streamingBinaryResponse(result.value, operation, cors, request);
163
176
  handedOff = true;
164
- return streamingBinaryResponse(result.value, operation, cors, request);
177
+ return streaming;
165
178
  }
166
179
  const applicationHeaders = applicationBinaryHeaders(result.value.headers);
167
180
  return new Response(toBody(result.value.body), {
@@ -215,8 +228,10 @@ export function mountRoutes(app, source, deliveryProvider, handler, active, next
215
228
  });
216
229
  }
217
230
  function streamingBinaryResponse(binary, operation, cors, request) {
218
- const iterator = binary.body[Symbol.asyncIterator]();
231
+ // Admit the reserved/invalid headers before acquiring the producer, so a refused render never
232
+ // captures the stream it would then have to abandon.
219
233
  const applicationHeaders = applicationBinaryHeaders(binary.headers);
234
+ const iterator = binary.body[Symbol.asyncIterator]();
220
235
  let terminal = false;
221
236
  let controller;
222
237
  let stopping;
@@ -269,14 +284,22 @@ function streamingBinaryResponse(binary, operation, cors, request) {
269
284
  },
270
285
  });
271
286
  operation.onCancel(stop);
272
- return new Response(body, {
273
- status: binary.status ?? 200,
274
- headers: {
275
- ...cors.response(request, Object.keys(applicationHeaders)),
276
- ...applicationHeaders,
277
- 'content-type': binary.mediaType,
278
- },
279
- });
287
+ try {
288
+ return new Response(body, {
289
+ status: binary.status ?? 200,
290
+ headers: {
291
+ ...cors.response(request, Object.keys(applicationHeaders)),
292
+ ...applicationHeaders,
293
+ 'content-type': binary.mediaType,
294
+ },
295
+ });
296
+ }
297
+ catch (cause) {
298
+ // A body-forbidding status (e.g. 204) rejects the Response after the producer was wired:
299
+ // close it and let the caller settle the operation rather than leaking its slot.
300
+ void stop('Binary response was refused.');
301
+ throw cause;
302
+ }
280
303
  }
281
304
  function isStreamingBinary(input) {
282
305
  return !(input.body instanceof Uint8Array);
@@ -542,6 +565,41 @@ function sameRoute(left, right, externalOrigin) {
542
565
  const b = new URL(right);
543
566
  return ((externalOrigin ?? a.origin) === b.origin && a.pathname === b.pathname && a.search === b.search);
544
567
  }
568
+ /**
569
+ * A request that mutates state or navigates to an effectful target. Non-safe HTTP methods always
570
+ * qualify; a GET or HEAD qualifies only as a top-level browser navigation (`Sec-Fetch-Mode:
571
+ * navigate`), the one GET shape that carries SameSite=Lax cookies cross-site. Programmatic GETs
572
+ * (`cors`/`no-cors`) are left untouched so read clients are never broken.
573
+ */
574
+ function isStateChanging(request) {
575
+ switch (request.method.toUpperCase()) {
576
+ case 'POST':
577
+ case 'PUT':
578
+ case 'PATCH':
579
+ case 'DELETE':
580
+ return true;
581
+ case 'GET':
582
+ case 'HEAD':
583
+ return request.headers.get('sec-fetch-mode') === 'navigate';
584
+ default:
585
+ return false;
586
+ }
587
+ }
588
+ /**
589
+ * Positive evidence that a request originates from another site. `Sec-Fetch-Site` is set by the
590
+ * browser and cannot be forged by page script, so when present it is authoritative: only
591
+ * `cross-site` is refused, and `same-origin`/`same-site`/`none` are trusted (a shared-domain
592
+ * subdomain deployment keeps working). When it is absent, a differing `Origin` is the next-best
593
+ * signal. Absence of both is treated as not-cross-site, so same-origin browsers and non-browser
594
+ * clients that omit these headers are never refused.
595
+ */
596
+ function isClearlyCrossSite(request, serviceOrigin) {
597
+ const site = request.headers.get('sec-fetch-site');
598
+ if (site !== null)
599
+ return site === 'cross-site';
600
+ const origin = request.headers.get('origin');
601
+ return origin !== null && origin !== serviceOrigin;
602
+ }
545
603
  function applicationBinaryHeaders(input) {
546
604
  const output = {};
547
605
  for (const [name, value] of Object.entries(input ?? {})) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/kernel-server",
3
- "version": "0.5.0-beta.70",
3
+ "version": "0.5.0-beta.71",
4
4
  "description": "Portable HTTP and WebSocket boundary for Astrale invocation, publication, installation, and issuer protocols",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -33,9 +33,9 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@astrale-os/kernel-core": "0.9.0-beta.62",
37
- "@astrale-os/kernel-dsl": "0.2.0-beta.44",
38
- "@astrale-os/kernel-protocol": "0.5.0-beta.68",
36
+ "@astrale-os/kernel-core": "0.9.0-beta.63",
37
+ "@astrale-os/kernel-dsl": "0.2.0-beta.45",
38
+ "@astrale-os/kernel-protocol": "0.5.0-beta.69",
39
39
  "hono": "^4.6.20"
40
40
  },
41
41
  "devDependencies": {