Skip to content

Commit 188b2db

Browse files
committed
fix(playback): keep Tigris media private
1 parent 2d442cd commit 188b2db

10 files changed

Lines changed: 101 additions & 82 deletions

File tree

apps/site/app/embed-fast/[assetId]/route.ts

Lines changed: 11 additions & 8 deletions
Large diffs are not rendered by default.

apps/site/app/embed-fast/route.test.mts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,11 @@ test("fast embed falls back to the real opener when inline startup is unavailabl
9595
});
9696

9797
assert.match(html, /src="https:\/\/api\.rend\.so\/v\/00000000-0000-0000-0000-000000000001\/opener\.mp4"/);
98-
assert.match(html, /rel="preload" as="video" href="https:\/\/api\.rend\.so\/v\/00000000-0000-0000-0000-000000000001\/opener\.mp4" type="video\/mp4" crossorigin="use-credentials" fetchpriority="high"/);
98+
assert.match(html, /rel="preload" as="video" href="https:\/\/api\.rend\.so\/v\/00000000-0000-0000-0000-000000000001\/opener\.mp4" type="video\/mp4" fetchpriority="high"/);
9999
assert.match(html, /data-rend-player-selected="opener"/);
100100
assert.match(html, /data-rend-player-artifact="opener\.mp4"/);
101101
assert.doesNotMatch(html, /src="[^"]*progressive\.mp4"/);
102+
assert.doesNotMatch(html, /crossorigin="use-credentials"/);
102103
assert.doesNotMatch(html, /playback_token|set-cookie|authorization/i);
103104
});
104105

@@ -244,21 +245,19 @@ test("fast embed route forwards bootstrap cookies to the document response by de
244245
assert.equal(response.headers.get("cache-control"), "no-store");
245246
assert.equal(response.headers.get("x-rend-fast-embed"), "1");
246247
assert.match(response.headers.get("link") ?? "", /rel=preconnect/);
247-
assert.match(response.headers.get("link") ?? "", /hls\/360p\/segment_00001\.m4s/);
248-
assert.match(response.headers.get("link") ?? "", /rel=preload; as=fetch/);
248+
assert.match(response.headers.get("link") ?? "", /opener\.mp4/);
249+
assert.match(response.headers.get("link") ?? "", /rel=preload; as=video/);
249250
assert.match(response.headers.get("set-cookie") ?? "", /__rend_playback=/);
250251
assert.equal(
251252
fetches[0]?.url,
252253
`https://www.rend.so/api/player/${ASSET_ID}`,
253254
);
254255
assert.equal(fetches[0]?.headers.get("x-vercel-ip-country"), "GB");
255-
assert.match(fetches[1]?.headers.get("cookie") ?? "", /__rend_playback=/);
256-
assert.match(fetches[1]?.headers.get("cookie") ?? "", /CloudFront-Policy=policy_value/);
257-
assert.match(fetches[1]?.headers.get("cookie") ?? "", /CloudFront-Signature=signature_value/);
258-
assert.match(fetches[1]?.headers.get("cookie") ?? "", /CloudFront-Key-Pair-Id=key_pair/);
256+
assert.equal(fetches.length, 1);
259257
assert.match(body, /autoplay/);
260258
assert.doesNotMatch(body, /controls/);
261-
assert.match(body, /mse_inline/);
259+
assert.match(body, /data-rend-player-selected="opener"/);
260+
assert.doesNotMatch(body, /crossorigin="use-credentials"/);
262261
assert.doesNotMatch(body, /playback_token|authorization/i);
263262
} finally {
264263
globalThis.fetch = originalFetch;

apps/site/lib/player-engine.test.mts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
initialPlaybackState,
55
initialSourceSelection,
66
playbackCrossOrigin,
7+
scriptedHlsAllowed,
78
startupPreloadHints,
89
watchHeartbeatDelta,
910
} from "./player-engine.ts";
@@ -87,7 +88,19 @@ test("startup preload hints avoid duplicating native HLS startup requests", () =
8788
hint.crossOrigin,
8889
hint.url,
8990
]),
90-
[["thumbnail.jpg", "image", "use-credentials", "https://ash-1.play.rend.so/v/asset/thumbnail.jpg"]]
91+
[["thumbnail.jpg", "image", undefined, "https://ash-1.play.rend.so/v/asset/thumbnail.jpg"]]
92+
);
93+
});
94+
95+
test("private Tigris media uses credentialed no-CORS playback", () => {
96+
assert.equal(playbackCrossOrigin(READY_BOOTSTRAP), undefined);
97+
assert.equal(scriptedHlsAllowed(READY_BOOTSTRAP, "https://www.rend.so/watch/asset"), false);
98+
assert.equal(
99+
scriptedHlsAllowed(
100+
{ ...READY_BOOTSTRAP, manifest_url: "https://www.rend.so/api/player/asset/master.m3u8" },
101+
"https://www.rend.so/watch/asset",
102+
),
103+
true,
91104
);
92105
});
93106

@@ -102,6 +115,7 @@ test("public playback uses anonymous cross-origin media requests", () => {
102115
};
103116

104117
assert.equal(playbackCrossOrigin(publicBootstrap), "anonymous");
118+
assert.equal(scriptedHlsAllowed(publicBootstrap, "https://www.rend.so/watch/asset"), true);
105119
assert.equal(startupPreloadHints(publicBootstrap, "hls")[0]?.crossOrigin, "anonymous");
106120
});
107121

apps/site/lib/player-engine.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type StartupPreloadHint = {
1717
artifactPath: string;
1818
as: "fetch" | "image" | "video";
1919
contentType?: string;
20-
crossOrigin: "anonymous" | "use-credentials";
20+
crossOrigin?: "anonymous";
2121
url: string;
2222
};
2323

@@ -123,7 +123,25 @@ export function playbackCredentialMode(data: WatchPlaybackBootstrapResponse | nu
123123
}
124124

125125
export function playbackCrossOrigin(data: WatchPlaybackBootstrapResponse | null | undefined) {
126-
return playbackCredentialMode(data) === "omit" ? "anonymous" : "use-credentials";
126+
// Tigris authorizes private playback with cookies, but its S3 CORS response
127+
// does not include Access-Control-Allow-Credentials. Omitting crossorigin
128+
// lets the media element use its credentialed no-CORS path; public playback
129+
// remains explicitly anonymous and CORS-readable.
130+
return playbackCredentialMode(data) === "omit" ? "anonymous" : undefined;
131+
}
132+
133+
export function scriptedHlsAllowed(
134+
data: WatchPlaybackBootstrapResponse | null | undefined,
135+
pageUrl: string,
136+
) {
137+
const ready = readyBootstrap(data);
138+
if (!ready?.manifest_url) return false;
139+
if (playbackCredentialMode(ready) === "omit") return true;
140+
try {
141+
return new URL(ready.manifest_url, pageUrl).origin === new URL(pageUrl).origin;
142+
} catch {
143+
return false;
144+
}
127145
}
128146

129147
function hlsStartupConfig(data: WatchPlaybackBootstrapReady) {
@@ -745,6 +763,12 @@ export function attachPlayback(
745763
const ready = readyBootstrap(options.initialBootstrap);
746764
telemetry?.playerLoad();
747765

766+
const applyCrossOrigin = (data: WatchPlaybackBootstrapReady | null) => {
767+
const value = playbackCrossOrigin(data);
768+
if (value) video.crossOrigin = value;
769+
else video.removeAttribute("crossorigin");
770+
};
771+
748772
const applyProgressiveSource = (
749773
selection: SourceSelection,
750774
data: WatchPlaybackBootstrapReady | null = ready,
@@ -753,7 +777,7 @@ export function attachPlayback(
753777
setSelection(player, selection);
754778
setState(player, "ready");
755779
telemetry?.sourceSelected(selection);
756-
video.crossOrigin = playbackCrossOrigin(data);
780+
applyCrossOrigin(data);
757781
if (video.currentSrc !== selection.url && video.getAttribute("src") !== selection.url) {
758782
video.src = selection.url;
759783
setAttribute(player, "data-rend-src-assigned-ms", Math.max(0, Math.round(performance.now())));
@@ -775,7 +799,7 @@ export function attachPlayback(
775799
setSelection(player, selection);
776800
setState(player, "ready");
777801
telemetry?.sourceSelected(selection);
778-
video.crossOrigin = playbackCrossOrigin(data);
802+
applyCrossOrigin(data);
779803
if (!video.currentSrc && !video.getAttribute("src")) {
780804
video.src = selection.url;
781805
setAttribute(player, "data-rend-src-assigned-ms", Math.max(0, Math.round(performance.now())));
@@ -786,7 +810,11 @@ export function attachPlayback(
786810
}
787811

788812
let Hls: HlsConstructor | null = null;
789-
if (data.manifest_url && (options.playbackEngine === "mse" || !nativeHls)) {
813+
if (
814+
data.manifest_url &&
815+
scriptedHlsAllowed(data, window.location.href) &&
816+
(options.playbackEngine === "mse" || !nativeHls)
817+
) {
790818
try {
791819
Hls = await loadHlsConstructor();
792820
} catch {
@@ -812,7 +840,7 @@ export function attachPlayback(
812840
setSelection(player, selection);
813841
setState(player, "ready");
814842
telemetry?.sourceSelected(selection);
815-
video.crossOrigin = playbackCrossOrigin(data);
843+
applyCrossOrigin(data);
816844
video.removeAttribute("src");
817845
video.load();
818846
hls = new Hls(hlsStartupConfig(data));

docs/deployment-aws.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,12 @@ The hosted architecture has no NAT gateway and no duplicate CDN:
129129
4. Fargate workers stream source ranges into ffmpeg instead of staging complete
130130
inputs. Each task has 4 vCPU, 8 GiB memory, and 100 GiB ephemeral storage.
131131
Attempts are immutable and a fenced PlanetScale lease alone can publish the
132-
winning canonical media and public playback aliases.
132+
winning canonical media and private playback aliases.
133133
5. The API and workers use PlanetScale over TLS. ClickHouse remains inside the
134134
VPC and is reached through the internal ALB's validated HTTPS hostname.
135-
6. The media bucket is private except for the immutable `v/*` alias prefix.
136-
Canonical media, processing attempts, and all source objects remain private.
137-
Asset UUIDs are unguessable, the public bootstrap already grants playback by
138-
asset ID, and deletion removes canonical and alias keys.
135+
6. The media and source buckets are private. The API issues short-lived signed
136+
cookies for immutable `v/*` aliases, while canonical media and processing
137+
attempts remain inaccessible. Deletion removes canonical and alias keys.
139138

140139
Default hard ceilings are API 2-6 tasks, workers 1-50, 50 non-deleted videos,
141140
250 GiB stored data, 10 open uploads, and two active media jobs per
@@ -150,8 +149,8 @@ billing controls.
150149

151150
Terraform owns the external Tigris contract. Its idempotent reconciler reads
152151
credentials directly from SSM, creates missing buckets, and enforces a private
153-
source bucket, private canonical media, scoped public playback aliases, CORS,
154-
locations, and the custom domain. Credential values never enter Terraform
152+
source bucket, private media and playback aliases, CORS, locations, the signed
153+
playback key, and the custom domain. Credential values never enter Terraform
155154
state.
156155

157156
See `infra/aws/README.md` for bootstrap, prerequisites, exact Terraform commands,

infra/aws/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ api.rend.so video.rend.so
1616
| |
1717
AWS WAF + public ALB Tigris delivery network
1818
| |
19-
TLS proxy public v/* aliases only
19+
TLS proxy signed private v/* aliases
2020
| |
2121
ECS API private canonical media
2222
@@ -42,8 +42,8 @@ AWS log and backup traffic.
4242

4343
- Tigris source and media buckets remain external to AWS, but Terraform owns
4444
their creation and security contract. Its apply-time reconciler reads the
45-
credentials directly from SSM, then enforces a private source bucket, private
46-
canonical media, scoped public `v/*` playback aliases, and CORS. Tigris
45+
credentials directly from SSM, then enforces private source and media
46+
buckets, signed `v/*` playback aliases, and CORS. Tigris
4747
object endpoints accept HTTPS only. Rend's 24-hour upload-session sweeper aborts each
4848
abandoned multipart upload and releases its reservation. Credential values never enter Terraform
4949
variables, plans, state, arguments, or logs.
@@ -130,8 +130,8 @@ Before the first platform apply:
130130
Tigris client used by CI: `npm install --global @tigrisdata/cli@3.4.3`.
131131
2. Create the ten SSM SecureString parameters referenced by the tfvars file. Secret
132132
values are populated outside Terraform so they do not enter state.
133-
3. Retain the legacy Tigris-compatible RSA key inputs until their existing SSM
134-
values are rotated out. Public alias playback does not use signed cookies.
133+
3. Retain the Tigris-compatible RSA key inputs. The API signs short-lived
134+
private playback cookies and Tigris verifies them at its custom domain.
135135

136136
```bash
137137
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out cloudfront-private.pem

infra/aws/platform/locals.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ locals {
4141
{ name = "REND_PLAYBACK_MODE", value = "tigris" },
4242
{ name = "REND_PLAYBACK_BASE_URL", value = local.playback_base_url },
4343
{ name = "REND_TIGRIS_PLAYBACK_BASE_URL", value = local.playback_base_url },
44-
{ name = "REND_PUBLIC_PLAYBACK_ENABLED", value = "true" },
44+
{ name = "REND_PUBLIC_PLAYBACK_ENABLED", value = "false" },
4545
{ name = "REND_PUBLIC_PLAYBACK_ALIAS_ENABLED", value = "true" },
4646
{ name = "REND_PUBLIC_PLAYBACK_ALIAS_BUCKET", value = var.tigris_media_bucket },
4747
{ name = "REND_PUBLIC_PLAYBACK_ALIAS_PREFIX", value = "v" },

infra/aws/scripts/provision-tigris.sh

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ ensure_bucket() {
130130
local bucket="$1"
131131
local location="$2"
132132
local cors_file="$3"
133-
local allow_public_policy="${4:-false}"
134133
local bucket_info
135134
local expected_location
136135
local actual_location
@@ -161,7 +160,7 @@ ensure_bucket() {
161160
--bucket "$bucket" \
162161
--query 'PolicyStatus.IsPublic' \
163162
--output text)"
164-
if [[ "$allow_public_policy" != "true" && "$public_policy" != "False" && "$public_policy" != "false" ]]; then
163+
if [[ "$public_policy" != "False" && "$public_policy" != "false" ]]; then
165164
echo "Tigris policy is public for bucket $bucket" >&2
166165
exit 1
167166
fi
@@ -184,35 +183,7 @@ ensure_bucket() {
184183
}
185184

186185
ensure_bucket "$TIGRIS_SOURCE_BUCKET" "$TIGRIS_SOURCE_LOCATION" "$work_dir/source-cors.json"
187-
ensure_bucket "$TIGRIS_MEDIA_BUCKET" "$TIGRIS_MEDIA_LOCATION" "$work_dir/media-cors.json" true
188-
189-
# Only immutable playback aliases are anonymously readable. Canonical media,
190-
# processing attempts, and source objects remain private. Asset identifiers are
191-
# unguessable UUIDs and the public API already grants playback by asset ID, so
192-
# signed cookies add no authorization boundary while preventing credentialed
193-
# browser playback on Tigris's S3-compatible CORS responses.
194-
jq -n --arg bucket "$TIGRIS_MEDIA_BUCKET" '{
195-
Version: "2012-10-17",
196-
Statement: [{
197-
Sid: "PublicReadPlaybackAliases",
198-
Effect: "Allow",
199-
Principal: "*",
200-
Action: ["s3:GetObject"],
201-
Resource: ["arn:aws:s3:::" + $bucket + "/v/*"]
202-
}]
203-
}' >"$work_dir/media-policy.json"
204-
tigris_s3api_bucket_config put-bucket-policy \
205-
--bucket "$TIGRIS_MEDIA_BUCKET" \
206-
--policy "file://$work_dir/media-policy.json" >/dev/null
207-
208-
media_public_policy="$(tigris_s3api get-bucket-policy-status \
209-
--bucket "$TIGRIS_MEDIA_BUCKET" \
210-
--query 'PolicyStatus.IsPublic' \
211-
--output text)"
212-
if [[ "$media_public_policy" != "True" && "$media_public_policy" != "true" ]]; then
213-
echo "Tigris playback alias policy is not public for bucket $TIGRIS_MEDIA_BUCKET" >&2
214-
exit 1
215-
fi
186+
ensure_bucket "$TIGRIS_MEDIA_BUCKET" "$TIGRIS_MEDIA_LOCATION" "$work_dir/media-cors.json"
216187

217188
playback_public_key_pem="$(printf '%s' "$TIGRIS_PLAYBACK_PUBLIC_KEY_PEM_B64" | base64 --decode)"
218189
if [[ "$playback_public_key_pem" != "-----BEGIN PUBLIC KEY-----"* ]]; then
@@ -263,4 +234,4 @@ aws ssm put-parameter \
263234
--overwrite \
264235
--no-cli-pager >/dev/null
265236

266-
echo "Tigris private source/canonical media, public playback aliases, and custom playback domain are reconciled."
237+
echo "Tigris private source/media buckets, signed playback key, and custom playback domain are reconciled."

services/rend-api/src/main.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3343,7 +3343,7 @@ async fn api_fast_embed_inner(
33433343
.as_deref()
33443344
.map(|value| query_flag(Some(value), true))
33453345
.unwrap_or(auto_play);
3346-
let inline_startup = if startup == "mse" {
3346+
let inline_startup = if startup == "mse" && playback_credential_mode == "omit" {
33473347
fast_embed_inline_startup(
33483348
state.as_ref(),
33493349
&response,
@@ -5115,10 +5115,10 @@ fn render_api_fast_embed_html(
51155115
html_escape(&selection.content_type)
51165116
)
51175117
};
5118-
let cross_origin = if playback_credential_mode == "omit" {
5119-
"anonymous"
5118+
let cross_origin_attr = if playback_credential_mode == "omit" {
5119+
r#" crossorigin="anonymous""#
51205120
} else {
5121-
"use-credentials"
5121+
""
51225122
};
51235123
let fetch_credentials = if playback_credential_mode == "omit" {
51245124
"omit"
@@ -5131,18 +5131,18 @@ fn render_api_fast_embed_html(
51315131
.first()
51325132
.map(|url| {
51335133
format!(
5134-
r#"<link rel="preload" as="fetch" href="{}" type="video/mp4" crossorigin="{}" fetchpriority="high">"#,
5134+
r#"<link rel="preload" as="fetch" href="{}" type="video/mp4"{} fetchpriority="high">"#,
51355135
html_escape(url),
5136-
cross_origin
5136+
cross_origin_attr
51375137
)
51385138
})
51395139
.unwrap_or_default()
51405140
} else if selection.content_type == "video/mp4" {
51415141
format!(
5142-
r#"<link rel="preload" as="video" href="{}" type="{}" crossorigin="{}" fetchpriority="high">"#,
5142+
r#"<link rel="preload" as="video" href="{}" type="{}"{} fetchpriority="high">"#,
51435143
html_escape(&selection.url),
51445144
html_escape(&selection.content_type),
5145-
cross_origin
5145+
cross_origin_attr
51465146
)
51475147
} else {
51485148
String::new()
@@ -5175,7 +5175,7 @@ body{{overflow:hidden}}
51755175
</head>
51765176
<body>
51775177
<main class="rend-fast" aria-label="Video player" data-rend-player-state="ready" data-rend-player-selected="{label}" data-rend-player-artifact="{artifact_path}" data-rend-ready-status="ready" data-rend-source-state="{source_state}" data-rend-playable-state="{playable_state}" data-rend-playback-engine="{playback_engine}" data-rend-document-start-ms="0" data-rend-bootstrap-ms="0" data-rend-asset-id="{asset_id}">
5178-
<video class="rend-fast__video"{source_attrs}{poster_attr}{auto_play_attr}{controls_attr}{muted_attr} playsinline preload="auto" crossorigin="{cross_origin}"></video>
5178+
<video class="rend-fast__video"{source_attrs}{poster_attr}{auto_play_attr}{controls_attr}{muted_attr} playsinline preload="auto"{cross_origin_attr}></video>
51795179
<div class="rend-fast__message" role="status" aria-live="polite">Ready</div>
51805180
</main>
51815181
<script>
@@ -5186,7 +5186,7 @@ body{{overflow:hidden}}
51865186
asset_id = html_escape(&response.asset_id),
51875187
artifact_path = html_escape(selected_artifact),
51885188
auto_play_js = if auto_play { "true" } else { "false" },
5189-
cross_origin = cross_origin,
5189+
cross_origin_attr = cross_origin_attr,
51905190
fallback_json = fallback_json,
51915191
fetch_credentials = fetch_credentials,
51925192
inline_startup_json = inline_startup_json,

0 commit comments

Comments
 (0)