Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/realm-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1109,8 +1109,15 @@ export class RealmServer {
.use(
cors({
origin: '*',
// Range/If-Range are here for native media playback: <audio>/<video>
// elements cannot attach Authorization, so the host's auth service
// worker re-issues their requests as mode:'cors' with the token
// injected. That rewrite turns the media element's Range header into
// an author header needing preflight approval — without Range in
// this list the preflight fails and the player errors before any
// bytes flow.
allowHeaders:
Comment thread
lukemelia marked this conversation as resolved.
'Authorization, Content-Type, If-Match, If-None-Match, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename, X-Boxel-During-Prerender, X-Boxel-Consuming-Realm, X-Boxel-Job-Id, X-Boxel-Job-Priority, X-Boxel-Logging-Correlation-Id, X-Grafana-Device-Id, X-Grafana-Action',
'Authorization, Content-Type, If-Match, If-None-Match, If-Range, Range, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename, X-Boxel-During-Prerender, X-Boxel-Consuming-Realm, X-Boxel-Job-Id, X-Boxel-Job-Priority, X-Boxel-Logging-Correlation-Id, X-Grafana-Device-Id, X-Grafana-Action',
// Without an explicit expose list, @koa/cors only emits the
// CORS-safelisted response headers (cache-control, content-*,
// expires, last-modified, pragma). ETag is not on that list,
Expand All @@ -1121,7 +1128,11 @@ export class RealmServer {
// protocol invisible to JS. Location/Retry-After are likewise
// non-safelisted; expose them so a cross-origin client can read
// the async-publish status monitor target off the 202 response.
exposeHeaders: 'ETag, Location, Retry-After',
// Content-Range/Accept-Ranges are what a cross-origin caller needs
// to reason about a 206 byte-range response (Content-Length is
// safelisted, but is listed for symmetry with the range pair).
exposeHeaders:
'ETag, Location, Retry-After, Content-Range, Accept-Ranges, Content-Length',
Comment thread
lukemelia marked this conversation as resolved.
allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS,QUERY',
// Cache the preflight response for 24 h. Without this @koa/cors
// omits Access-Control-Max-Age and Chrome falls back to its
Expand Down
2 changes: 2 additions & 0 deletions packages/realm-server/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ const ALL_TEST_FILES: string[] = [
'./file-watcher-events-test',
'./full-index-on-startup-test',
'./full-reindex-test',
'./http-range-test',
'./range-request-test',
'./http2-keepalive-test',
'./indexing-test',
'./lazy-mount-test',
Expand Down
61 changes: 61 additions & 0 deletions packages/realm-server/tests/range-request-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import QUnit from 'qunit';
const { module, test } = QUnit;
import { basename } from 'path';
import type { Test, SuperTest } from 'supertest';
import {
setupPermissionedRealmCached,
withRealmPath,
Expand All @@ -16,6 +17,7 @@ module(basename(import.meta.filename), function () {
module('Realm-specific Endpoints | Range requests', function (hooks) {
let realmURL = new URL('http://127.0.0.1:4444/test/');
let request: RealmRequest;
let serverRequest: SuperTest<Test>;

setupPermissionedRealmCached(hooks, {
fixture: 'simple',
Expand All @@ -25,6 +27,7 @@ module(basename(import.meta.filename), function () {
'@node-test_realm:localhost': ['read', 'realm-owner'],
},
onRealmSetup: (args) => {
serverRequest = args.request;
request = withRealmPath(args.request, realmURL);
},
});
Expand Down Expand Up @@ -223,5 +226,63 @@ module(basename(import.meta.filename), function () {

assert.strictEqual(response.status, 304, 'HTTP 304 status');
});

// CORS treatment of byte-range media requests. Native <audio>/<video>
// elements cannot attach Authorization, so the host's auth service worker
// re-issues their requests as mode:'cors' with the token injected. That
// rewrite turns the media element's Range header into an author header
// needing preflight approval, and makes the 206's descriptive headers
// (Content-Range, Accept-Ranges) visible to the player's loading stack
// only when exposed.
test('preflight approves Range and If-Range author headers', async function (assert) {
let response = await serverRequest
.options('/test/preflight.png')
.set('Origin', 'https://app.example')
.set('Access-Control-Request-Method', 'GET')
.set(
'Access-Control-Request-Headers',
'range, if-range, authorization',
);

assert.strictEqual(response.status, 204, 'HTTP 204 status');
let allowed = (response.headers['access-control-allow-headers'] ?? '')
.toLowerCase()
.split(/,\s*/);
assert.true(allowed.includes('range'), 'Range is approved');
assert.true(allowed.includes('if-range'), 'If-Range is approved');
assert.true(
allowed.includes('authorization'),
'Authorization is approved',
);
});

test('a ranged response exposes its range headers to cross-origin JS', async function (assert) {
await uploadSample('/cors-expose.png');
let response = await getSample('/cors-expose.png')
.set('Origin', 'https://app.example')
.set('Range', 'bytes=2-5');

assert.strictEqual(response.status, 206, 'HTTP 206 status');
assert.strictEqual(
response.headers['access-control-allow-origin'],
'*',
'the response is a CORS response, without which no expose list is consulted',
);
let exposed = (response.headers['access-control-expose-headers'] ?? '')
.toLowerCase()
.split(/,\s*/);
assert.true(
exposed.includes('content-range'),
'Content-Range is exposed',
);
assert.true(
exposed.includes('accept-ranges'),
'Accept-Ranges is exposed',
);
assert.true(
exposed.includes('content-length'),
'Content-Length is exposed',
);
});
});
});
9 changes: 8 additions & 1 deletion packages/runtime-common/create-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@ export function createResponse({
// which is why `Retry-After` appears below: readiness pairs it with
// `X-Boxel-Not-Ready` on a 503, and a cross-origin caller that could read
// the stage but not the retry hint would only have half the answer.
// Content-Range/Accept-Ranges must be exposed because the host's auth
// service worker hands the CORS-filtered Response from its own fetch()
// straight to the media element via respondWith. A header absent from
// this list is pruned from that filtered Response, so it is invisible
// to the player's loading stack, not just to app JS. Content-Length is
// CORS-safelisted and survives regardless; it is named alongside the
// pair so the range trio reads as one unit.
'Access-Control-Expose-Headers':
'X-Boxel-Realm-Url,X-Boxel-Realm-Public-Readable,X-Boxel-Realm-Archived,X-Boxel-Canonical-Path,X-Boxel-Not-Ready,Authorization,Cache-Control,ETag,Retry-After',
'X-Boxel-Realm-Url,X-Boxel-Realm-Public-Readable,X-Boxel-Realm-Archived,X-Boxel-Canonical-Path,X-Boxel-Not-Ready,Authorization,Cache-Control,ETag,Retry-After,Content-Range,Accept-Ranges,Content-Length',
},
});
}
Loading