diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_decode.c b/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_decode.c index 38319b7df3..a2f626e967 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_decode.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_decode.c @@ -508,6 +508,7 @@ static void feed_extractor_sample(basis_decoder_t* d, AMediaCodec* codec, int tr if (ii < 0) return; size_t cap = 0; uint8_t* buf = AMediaCodec_getInputBuffer(codec, ii, &cap); + if (!buf) { AMediaCodec_queueInputBuffer(codec, ii, 0, 0, 0, 0); return; } ssize_t sz = AMediaExtractor_readSampleData(d->extractor, buf, cap); if (sz < 0) { AMediaCodec_queueInputBuffer(codec, ii, 0, 0, 0, AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM); @@ -858,17 +859,23 @@ int basis_decoder_set_audio_format(basis_decoder_t* d, basis_codec_t codec, int basis_decoder_submit_video(basis_decoder_t* d, const uint8_t* annexb, int len, int64_t pts_us, int key) { (void)key; - if (!d || !d->vcodec) return -1; + if (!d || !d->vcodec || !annexb || len <= 0) return -1; + int rc = -1; ssize_t ii = AMediaCodec_dequeueInputBuffer(d->vcodec, 2000); if (ii >= 0) { size_t cap = 0; - uint8_t* buf = AMediaCodec_getInputBuffer(d->vcodec, ii, &cap); - int n = len < (int)cap ? len : (int)cap; - memcpy(buf, annexb, n); - AMediaCodec_queueInputBuffer(d->vcodec, ii, 0, n, pts_us, 0); + uint8_t* buf = AMediaCodec_getInputBuffer(d->vcodec, ii, &cap); /* size_t: no sign-cast */ + if (buf && (size_t)len <= cap) { + memcpy(buf, annexb, (size_t)len); + rc = (AMediaCodec_queueInputBuffer(d->vcodec, ii, 0, (size_t)len, pts_us, 0) == AMEDIA_OK) ? 0 : -1; + } else { + /* NULL buffer or the AU doesn't fit: never queue a partial frame — it + * decodes to corruption. Release the slot empty and report the drop. */ + AMediaCodec_queueInputBuffer(d->vcodec, ii, 0, 0, pts_us, 0); + } } drain_video_output(d); - return 0; + return rc; } /* Source-order -> WAVE-order channel map for the Blu-ray HDMV LPCM @@ -928,13 +935,14 @@ int basis_decoder_submit_audio(basis_decoder_t* d, const uint8_t* data, int len, if (!d || !data || len <= 0) return -1; if (d->ac == BASIS_CODEC_LPCM) { submit_lpcm(d, data, len, pts_us); return 0; } if (!d->acodec) return -1; + int rc = -1; ssize_t ii = AMediaCodec_dequeueInputBuffer(d->acodec, 2000); if (ii >= 0) { size_t cap = 0; uint8_t* buf = AMediaCodec_getInputBuffer(d->acodec, ii, &cap); - if ((size_t)len <= cap) { + if (buf && (size_t)len <= cap) { memcpy(buf, data, (size_t)len); - AMediaCodec_queueInputBuffer(d->acodec, ii, 0, len, pts_us, 0); + rc = (AMediaCodec_queueInputBuffer(d->acodec, ii, 0, len, pts_us, 0) == AMEDIA_OK) ? 0 : -1; } else { /* Never feed a partial frame — it decodes to an error + silence. * max-input-size should prevent this; return the buffer empty if not. */ @@ -942,7 +950,7 @@ int basis_decoder_submit_audio(basis_decoder_t* d, const uint8_t* data, int len, } } drain_audio_output(d); - return 0; + return rc; } /* ---- render thread + accessors ----------------------------------------- */ diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_vk.c b/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_vk.c index 1954eee285..31a443918c 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_vk.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/android/basis_android_vk.c @@ -96,10 +96,12 @@ struct basis_vk_present { * the YCbCr->RGB render pass, and rebuild that when Unity rotates the * underlying VkImage (rare). */ void* unityNativeTex; - VkImage cachedUnityImage; /* the VkImage AccessTexture returned for unityNativeTex */ + int unityDirty; /* handle changed on the main thread; the render thread drops the fbo */ + VkImage cachedUnityImage; /* the VkImage AccessTexture returned for unityNativeTex (render thread) */ VkImageView unityImageView; VkFramebuffer fbo; - int unityW, unityH; + int fboW, fboH; /* extent the fbo was built with — render-thread-owned, distinct from unityW/H */ + int unityW, unityH; /* C#-registered RenderTexture size; written by the setter under v->lock */ int unityFormat; /* VkFormat returned by AccessTexture (UNORM or SRGB) */ uint64_t frameCounter; @@ -407,7 +409,7 @@ static void destroy_unity_fbo(basis_vk_present* v) { * or rotated under us). The image itself is OWNED BY UNITY — we never destroy * it, only the view and framebuffer we created on top. */ static int ensure_unity_fbo(basis_vk_present* v, VkImage image, VkFormat format, int w, int h) { - if (v->fbo && v->cachedUnityImage == image && v->unityW == w && v->unityH == h) return 0; + if (v->fbo && v->cachedUnityImage == image && v->fboW == w && v->fboH == h) return 0; destroy_unity_fbo(v); VkImageViewCreateInfo vci = { VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO }; @@ -434,24 +436,28 @@ static int ensure_unity_fbo(basis_vk_present* v, VkImage image, VkFormat format, if (vkCreateFramebuffer(v->device, &fci, NULL, &v->fbo) != VK_SUCCESS) return -1; v->cachedUnityImage = image; - v->unityW = w; - v->unityH = h; + v->fboW = w; + v->fboH = h; v->unityFormat = (int)format; return 0; } void basis_vk_set_output_texture(basis_vk_present* v, void* native_texture, int w, int h) { if (!v) return; - /* If the handle changed, drop the cached framebuffer; next render_update will - * rebuild against the new Unity image. We intentionally don't touch - * cachedUnityImage here because AccessTexture may legitimately return the - * same VkImage for a recreated RenderTexture if Unity reused the slot. */ - if (v->unityNativeTex != native_texture) { - destroy_unity_fbo(v); + /* Runs on the main thread while the render thread may be mid-present using the + * framebuffer/image-view. Don't destroy them here — just record the change + * under the lock and let render_update drop+rebuild the fbo on its own thread + * (unityDirty). We intentionally don't touch cachedUnityImage because + * AccessTexture may legitimately return the same VkImage for a recreated + * RenderTexture if Unity reused the slot. */ + pthread_mutex_lock(&v->lock); + if (v->unityNativeTex != native_texture || v->unityW != w || v->unityH != h) { v->unityNativeTex = native_texture; + v->unityDirty = 1; /* a same-handle resize still needs the fbo rebuilt */ } v->unityW = w; v->unityH = h; + pthread_mutex_unlock(&v->lock); } /* ---- plugin-owned submission objects ------------------------------------ */ @@ -494,17 +500,31 @@ static int ensure_cmd_objects(basis_vk_present* v) { int basis_vk_render_update(basis_vk_present* v) { if (!v || !v->device || !v->getAHBProps) return 0; - /* Without a registered Unity output texture we have nowhere to render. C# is - * expected to call basis_media_set_output_texture once TryGetVideoSize - * returns a non-zero size; until then the demuxer's AHBs sit in v->pending. */ - if (!v->unityNativeTex) return 0; + /* All Unity-registration state is read as one locked snapshot — no unlocked + * peek at unityNativeTex first. Without a registered output texture there is + * nowhere to render (C# calls basis_media_set_output_texture once + * TryGetVideoSize is non-zero; until then the demuxer's AHBs sit in pending). */ AHardwareBuffer* ahb = NULL; int w, h; float uv[4]; + void* unityTex; int unityDirty; int regW, regH; pthread_mutex_lock(&v->lock); - ahb = v->pending; v->pending = NULL; w = v->w; h = v->h; + unityTex = v->unityNativeTex; regW = v->unityW; regH = v->unityH; + unityDirty = v->unityDirty; + w = v->w; h = v->h; uv[0] = v->uv[0]; uv[1] = v->uv[1]; uv[2] = v->uv[2]; uv[3] = v->uv[3]; + /* Only detach the pending frame when there's a texture to render it into — + * otherwise it stays queued (the producer replaces + releases it) instead of + * being dropped here with its AHB reference leaked. */ + if (unityTex) { + ahb = v->pending; v->pending = NULL; + if (ahb) v->unityDirty = 0; /* consume the dirty flag only when this pass rebuilds+renders */ + } pthread_mutex_unlock(&v->lock); + if (!unityTex) return 0; if (!ahb) return 0; + /* Handle changed on the main thread: drop the old framebuffer/image-view here, + * on the render thread, so nothing is destroyed under a present in flight. */ + if (unityDirty) destroy_unity_fbo(v); /* AHB format + memory properties (drives the ycbcr conversion + allocation) */ VkAndroidHardwareBufferFormatPropertiesANDROID fmtProps = { VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_FORMAT_PROPERTIES_ANDROID }; @@ -520,16 +540,20 @@ int basis_vk_render_update(basis_vk_present* v) { * it. The render pass takes the attachment from UNDEFINED and returns it * SHADER_READ_ONLY, so no layout coordination with Unity is needed. */ uint64_t unityImageU64 = 0; int unityFormat = 0, unityW = 0, unityH = 0; - if (!basis_gfx_vk_access_texture(v->unityNativeTex, + if (!basis_gfx_vk_access_texture(unityTex, &unityImageU64, &unityFormat, &unityW, &unityH)) { AHardwareBuffer_release(ahb); return 0; } VkImage unityImage = (VkImage)(uintptr_t)unityImageU64; - if (ensure_unity_fbo(v, unityImage, (VkFormat)unityFormat, - unityW > 0 ? unityW : w, - unityH > 0 ? unityH : h) != 0) { + /* One target extent for both the framebuffer and the render area, or the + * render pass area can disagree with the framebuffer it renders into. Prefer + * the RenderTexture's actual extent (AccessTexture), then the registered size, + * then the source AHB. */ + int targetW = unityW > 0 ? unityW : (regW > 0 ? regW : w); + int targetH = unityH > 0 ? unityH : (regH > 0 ? regH : h); + if (ensure_unity_fbo(v, unityImage, (VkFormat)unityFormat, targetW, targetH) != 0) { AHardwareBuffer_release(ahb); return 0; } @@ -572,7 +596,7 @@ int basis_vk_render_update(basis_vk_present* v) { vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, NULL, 0, NULL, 1, &toRead); - int rw = v->unityW, rh = v->unityH; + int rw = targetW, rh = targetH; VkRenderPassBeginInfo rp = { VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO }; rp.renderPass = v->renderPass; rp.framebuffer = v->fbo; rp.renderArea.extent.width = (uint32_t)rw; rp.renderArea.extent.height = (uint32_t)rh; diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c index 4f950754f4..79ffaf5613 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_core.c @@ -227,6 +227,62 @@ int basis_engine_is_paused(basis_media_engine_t* e) { return e ? e->paused : 0; int basis_engine_is_running(basis_media_engine_t* e) { return e ? e->running : 0; } int basis_engine_is_paced(basis_media_engine_t* e) { return e ? e->paced : 0; } +/* ---- render-event liveness registry ------------------------------------ + * OnRenderEvent (Unity render thread) is handed the engine pointer and can fire + * concurrently with basis_media_close on the main thread. C# quiesces render + * events before closing, but a stale event must be a safe no-op, not a + * use-after-free. Every open engine is registered here; basis_engine_render_event + * dispatches under g_registry_lock only while the engine is still registered, and + * close removes it under the same lock — waiting out any in-flight event — before + * it frees the decoder and engine. + * + * Engines are keyed by pointer, so this stops a dispatch against a freed engine but + * not the narrow ABA case where a delayed event's pointer matches a *new* engine + * that reused the freed address. For the shipping C# binding that is benign: it + * issues only RENDER_UPDATE (idempotent — republishes the current frame). But + * RENDER_RELEASE is part of the public render-event ABI, and a caller that delivers + * one across a close+reopen could tear down the reused engine's decoder — so this + * registry's ABA-safety is only as strong as "no RELEASE is delivered after close." + * Closing the window fully needs a generation-stamped handle in the event payload + * (a C# ABI change) — deliberately out of scope here. */ +#define BASIS_MAX_ENGINES 64 +static basis_mutex_t g_registry_lock; +static int g_registry_ready; +static basis_media_engine_t* g_engines[BASIS_MAX_ENGINES]; + +/* opens run on Unity's main thread, so first-use init needs no extra guard. */ +static void registry_ensure(void) { + if (!g_registry_ready) { mutex_init(&g_registry_lock); g_registry_ready = 1; } +} +static int registry_add(basis_media_engine_t* e) { + registry_ensure(); + int ok = 0; + mutex_lock(&g_registry_lock); + for (int i = 0; i < BASIS_MAX_ENGINES; ++i) if (!g_engines[i]) { g_engines[i] = e; ok = 1; break; } + mutex_unlock(&g_registry_lock); + return ok; /* 0 => registry full */ +} +static void registry_remove(basis_media_engine_t* e) { + if (!g_registry_ready) return; + mutex_lock(&g_registry_lock); + for (int i = 0; i < BASIS_MAX_ENGINES; ++i) if (g_engines[i] == e) { g_engines[i] = NULL; break; } + mutex_unlock(&g_registry_lock); +} + +void basis_engine_render_event(basis_media_engine_t* e, int event_id) { + if (!e || !g_registry_ready) return; + mutex_lock(&g_registry_lock); + int live = 0; + for (int i = 0; i < BASIS_MAX_ENGINES; ++i) if (g_engines[i] == e) { live = 1; break; } + /* Dispatch under the lock so registry_remove (in close) blocks until this + * returns — the decoder can't be freed while a render event is using it. */ + if (live && e->decoder) { + if (event_id == BASIS_RENDER_UPDATE) basis_decoder_render_update(e->decoder); + else if (event_id == BASIS_RENDER_RELEASE) basis_decoder_render_release(e->decoder); + } + mutex_unlock(&g_registry_lock); +} + /* Real-time delivery pacing. Blocks the demux thread so an access unit is handed to the * decoder no more than BASIS_PACE_LEAD_US ahead of a fixed 1x clock anchored to the first * AU — stalling the socket read (TCP backpressure) so a faster-than-real-time source can't @@ -1177,6 +1233,22 @@ static basis_media_engine_t* open_impl(const char* url, const char* audio_url, i } if (has_audio) e->audio_thread_started = 1; + /* Live now: the pointer is about to reach C#, which may issue render events. + * Registered last so no partially-built engine is ever visible to a dispatch. + * If the registry is full (too many concurrent players), fail cleanly rather + * than hand back an engine whose render events would be silently ignored. */ + if (!registry_add(e)) { + e->running = 0; + thread_join(e); + audio_thread_join(e); + basis_decoder_destroy(e->decoder); + basis_io_global_shutdown(); + basis_caption_destroy(e->captions); + mutex_destroy(&e->submit_lock); + mutex_destroy(&e->lock); + free(e); + return NULL; + } return e; } @@ -1195,8 +1267,14 @@ BASIS_API basis_media_engine_t* BASIS_CALL basis_media_open_dual(const char* vid BASIS_API void BASIS_CALL basis_media_close(basis_media_engine_t* e) { if (!e) return; - /* Stop the demux threads first so nothing submits while we tear down. Both - * legs observe the same running flag; join both before freeing the decoder. */ + /* Deregister first, before anything is torn down: this blocks until any + * in-flight render event returns and makes every later one a no-op, so no + * render callback can touch the decoder while the demux threads are still + * exiting or the decoder is being freed. */ + registry_remove(e); + + /* Stop the demux threads so nothing submits while we tear down. Both legs + * observe the same running flag; join both before freeing the decoder. */ e->running = 0; thread_join(e); audio_thread_join(e); diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_internal.h b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_internal.h index 0fcf8a0b17..22b96fe1c4 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_internal.h +++ b/Basis/Packages/com.basis.mediaplayer/Native~/basis_media_internal.h @@ -228,6 +228,12 @@ void basis_engine_set_state(basis_media_engine_t* engine, basis_media_sta void basis_engine_set_error(basis_media_engine_t* engine, const char* message); basis_decoder_t* basis_engine_get_decoder(basis_media_engine_t* engine); +/* Render-thread entry point (the Unity plugin's OnRenderEvent forwards here). The + * engine pointer comes from Unity and can arrive after basis_media_close has freed + * it; this checks a liveness registry under a lock and no-ops on a stale engine, + * so a late render event can't use-after-free. event_id is a BASIS_RENDER_* value. */ +void basis_engine_render_event(basis_media_engine_t* engine, int event_id); + /* Consulted by the platform backend: paused freezes video publishing and mutes * audio reads; running going to 0 tells decode/demux loops to unwind. */ int basis_engine_is_paused(basis_media_engine_t* engine); diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c index 6e76e26a6a..fb188f667b 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_hls.c @@ -9,6 +9,8 @@ #include "basis_hls.h" #include "../basis_media_internal.h" /* BASIS_READ_REPOSITION */ +#include "basis_io.h" /* basis_io_host_is_blocked (SSRF guard) */ +#include "basis_url.h" /* basis_url_parse */ #include #include @@ -235,11 +237,36 @@ static void resolve_url(const char* base, const char* ref, char* out, int outsz) /* ---- playlist fetch ------------------------------------------------------ */ +/* SSRF gate for every URL a playlist steers us to. The managed layer validates + * only the entry URL; variant/segment/map URIs come from the (attacker-controlled) + * playlist body and are followed here, so re-check each one: it must stay on + * http(s) and its host must not resolve to a non-global-unicast address. The + * platform HTTP stacks (WinHTTP / JNI) don't apply this guard themselves. + * + * This is a pre-check: it blocks literal internal addresses and hosts that resolve + * private. It does NOT close two provider-side bypasses — active DNS rebinding (the + * platform stack re-resolves the name when it connects) and an allowed URL that + * redirects to an internal host (WinHTTP/JNI follow redirects). Fully closing those + * needs connect-by-pinned-IP plus per-redirect re-validation and connected-peer + * verification at the HTTP-provider boundary — tracked as a follow-up. */ +/* out_blocked (nullable) distinguishes a deterministic policy rejection (bad + * scheme/host — retrying can never succeed) from a transient provider open + * failure, so a caller can terminate on the former instead of busy-looping. */ +static void* hls_guarded_open(basis_hls_t* h, const char* url, int* out_blocked) { + if (out_blocked) *out_blocked = 1; /* set for the policy-reject early returns */ + basis_url_t u; + if (basis_url_parse(url, &u) != 0) return NULL; + if (strcmp(u.scheme, "http") != 0 && strcmp(u.scheme, "https") != 0) return NULL; + if (basis_io_host_is_blocked(u.host)) return NULL; + if (out_blocked) *out_blocked = 0; /* passed policy; any NULL below is transient */ + return h->http.open(url); +} + /* GET `url` fully into a NUL-terminated buffer (caller frees). Returns length, * or <0 on error / stop. */ -static int fetch_text(basis_hls_t* h, const char* url, char** out) { +static int fetch_text(basis_hls_t* h, const char* url, char** out, int* out_blocked) { *out = NULL; - void* ctx = h->http.open(url); + void* ctx = hls_guarded_open(h, url, out_blocked); if (!ctx) return -1; int cap = 16384, len = 0; @@ -475,8 +502,9 @@ static int reload_and_enqueue(basis_hls_t* h) { } char* text = NULL; - int n = fetch_text(h, url, &text); - if (n < 0) { free(text); return -1; } + int blocked = 0; + int n = fetch_text(h, url, &text, &blocked); + if (n < 0) { free(text); return blocked ? -2 : -1; } /* -2 = policy-blocked (deterministic) */ hls_playlist_t pl; parse_media_playlist(h->media_url, text, &pl); @@ -569,14 +597,27 @@ static void hls_producer(basis_hls_t* h) { } if (!h->seg_ctx) { if (h->is_fmp4 && !h->map_served && h->map_uri[0]) { - h->seg_ctx = h->http.open(h->map_uri); /* fMP4 init segment first */ + int blocked = 0; + h->seg_ctx = hls_guarded_open(h, h->map_uri, &blocked); /* fMP4 init segment first */ + if (!h->seg_ctx) { + /* A policy-blocked map can never load and its fragments are + * useless without it, so stop instead of spinning; a transient + * open failure backs off and retries. */ + if (blocked) break; + if (!hls_should_run(h)) break; + hls_sleep_ms(50); + continue; + } h->map_served = 1; - if (!h->seg_ctx) continue; } else { const char* next = queue_pop(h, NULL); if (next) { - h->seg_ctx = h->http.open(next); - if (!h->seg_ctx) continue; /* skip a transient open failure */ + int blocked = 0; + h->seg_ctx = hls_guarded_open(h, next, &blocked); + if (!h->seg_ctx) { + if (blocked) break; /* policy-blocked (SSRF): fail playback deterministically */ + continue; /* transient: skip; the next pop advances */ + } h->empty_reloads = 0; } else if (h->endlist_seen) { /* VOD exhausted. Arbitrate against a late seek under the lock: @@ -592,6 +633,7 @@ static void hls_producer(basis_hls_t* h) { } else { int r = reload_and_enqueue(h); if (r > 0) { h->empty_reloads = 0; } + else if (r == -2) break; /* playlist policy-blocked: retrying can't recover */ else if (r < 0) { if (!hls_should_run(h)) break; hls_sleep_ms(50); /* transient fetch error — back off and retry */ @@ -666,14 +708,14 @@ void* basis_hls_open(const char* url, const basis_http_provider_t* http, /* Fetch the entry playlist; follow one master->media indirection. */ char* text = NULL; - if (fetch_text(h, url, &text) < 0 || !text) { free(text); free(h); return NULL; } + if (fetch_text(h, url, &text, NULL) < 0 || !text) { free(text); free(h); return NULL; } if (playlist_is_master(text)) { char media[HLS_MAX_URI]; if (!master_pick_variant(h, url, text, media, sizeof(media))) { free(text); free(h); return NULL; } snprintf(h->media_url, sizeof(h->media_url), "%s", media); free(text); - if (fetch_text(h, h->media_url, &text) < 0 || !text) { free(text); free(h); return NULL; } + if (fetch_text(h, h->media_url, &text, NULL) < 0 || !text) { free(text); free(h); return NULL; } } else { snprintf(h->media_url, sizeof(h->media_url), "%s", url); } diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_io.c b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_io.c index 28798104e7..855c70416d 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_io.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_io.c @@ -75,15 +75,26 @@ static int local_allowed(void) { return v && v[0]; } -static int ipv4_octets_blocked(uint8_t b0, uint8_t b1) { - if (b0 == 0) return 1; /* 0/8 unspecified */ - if (b0 == 10) return 1; /* 10/8 */ +/* The IANA IPv4 Special-Purpose Address Registry entries that are "Globally + * Reachable: False" — i.e. everything that is not a public unicast destination. + * Kept in lockstep with the managed guard (BasisMediaPlayerSecurity.IsBlockedAddress) + * — change both together. See + * https://www.iana.org/assignments/iana-ipv4-special-registry/ */ +static int ipv4_octets_blocked(uint8_t b0, uint8_t b1, uint8_t b2) { + if (b0 == 0) return 1; /* 0/8 "this network" */ + if (b0 == 10) return 1; /* 10/8 private */ if (b0 == 127) return 1; /* 127/8 loopback */ if (b0 == 100 && (b1 & 0xC0) == 64) return 1; /* 100.64/10 CGNAT */ if (b0 == 169 && b1 == 254) return 1; /* 169.254/16 link-local (metadata) */ - if (b0 == 172 && b1 >= 16 && b1 <= 31) return 1; /* 172.16/12 */ - if (b0 == 192 && b1 == 168) return 1; /* 192.168/16 */ - if (b0 >= 224) return 1; /* multicast/reserved */ + if (b0 == 172 && b1 >= 16 && b1 <= 31) return 1; /* 172.16/12 private */ + if (b0 == 192 && b1 == 0 && b2 == 0) return 1; /* 192.0.0/24 IETF protocol assignments */ + if (b0 == 192 && b1 == 0 && b2 == 2) return 1; /* 192.0.2/24 TEST-NET-1 */ + if (b0 == 192 && b1 == 88 && b2 == 99) return 1; /* 192.88.99/24 6to4 relay anycast (deprecated) */ + if (b0 == 192 && b1 == 168) return 1; /* 192.168/16 private */ + if (b0 == 198 && (b1 & 0xFE) == 18) return 1; /* 198.18/15 benchmarking */ + if (b0 == 198 && b1 == 51 && b2 == 100) return 1; /* 198.51.100/24 TEST-NET-2 */ + if (b0 == 203 && b1 == 0 && b2 == 113) return 1; /* 203.0.113/24 TEST-NET-3 */ + if (b0 >= 224) return 1; /* 224/4 multicast + 240/4 reserved (incl. 255.255.255.255) */ return 0; } @@ -95,7 +106,7 @@ static int sockaddr_is_blocked(const struct sockaddr* sa) { if (sa->sa_family == AF_INET) { const struct sockaddr_in* s = (const struct sockaddr_in*)sa; const uint8_t* b = (const uint8_t*)&s->sin_addr.s_addr; /* network order = octets in order */ - return ipv4_octets_blocked(b[0], b[1]); + return ipv4_octets_blocked(b[0], b[1], b[2]); } if (sa->sa_family == AF_INET6) { const struct sockaddr_in6* s = (const struct sockaddr_in6*)sa; @@ -112,15 +123,36 @@ static int sockaddr_is_blocked(const struct sockaddr* sa) { int mapped = 1; for (i = 0; i < 10; i++) if (b[i]) { mapped = 0; break; } if (mapped && b[10] == 0xFF && b[11] == 0xFF) /* ::ffff:a.b.c.d */ - return ipv4_octets_blocked(b[12], b[13]); + return ipv4_octets_blocked(b[12], b[13], b[14]); } if (b[0] == 0x20 && b[1] == 0x02) /* 2002::/16 6to4 */ - return ipv4_octets_blocked(b[2], b[3]); + return ipv4_octets_blocked(b[2], b[3], b[4]); return 0; } return 1; } +int basis_io_host_is_blocked(const char* host) { + if (!host || !host[0]) return 1; + if (local_allowed()) return 0; + + struct addrinfo hints, *res = NULL, *ai; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + /* Fail closed: a name we can't resolve here could resolve to a private + * address at the platform stack's own lookup a moment later. */ + if (getaddrinfo(host, NULL, &hints, &res) != 0 || !res) return 1; + + /* Block if ANY resolved address is non-global — a host with both a public + * and a private record must not be usable to reach the private one. */ + int blocked = 0; + for (ai = res; ai; ai = ai->ai_next) + if (sockaddr_is_blocked(ai->ai_addr)) { blocked = 1; break; } + freeaddrinfo(res); + return blocked; +} + basis_io_t* basis_io_connect(const char* host, int port, int timeout_ms) { if (!host || port <= 0) return NULL; diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_io.h b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_io.h index 046a1aff05..ce8ad7e927 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_io.h +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_io.h @@ -56,6 +56,14 @@ int basis_io_send(basis_io_t* io, const uint8_t* buf, int len); * error. A socket with a pending error reports readable; the next read fails. */ int basis_io_poll_read(basis_io_t** ios, int n, int timeout_ms); +/* SSRF pre-check for a host about to be fetched through a platform HTTP stack + * (WinHTTP / JNI HttpsURLConnection) that does not itself apply the + * non-global-unicast guard basis_io_connect enforces. Resolves the name and + * returns 1 if it is empty, unresolvable (fail-closed), or resolves to any + * non-global-unicast address (loopback / RFC1918 / link-local / ULA / + * multicast). Honours the same BASIS_MEDIA_ALLOW_LOCAL escape hatch. */ +int basis_io_host_is_blocked(const char* host); + /* Process-wide one-time init/teardown (WSAStartup on Windows; no-op elsewhere). */ void basis_io_global_init(void); void basis_io_global_shutdown(void); diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_mp4.c b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_mp4.c index bff80d65b6..82f83e95e7 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_mp4.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_mp4.c @@ -539,17 +539,22 @@ static void parse_chunk_offsets(mp4_ctab_t* c, const uint8_t* p, int len, int is c->chunk_count = n; } -static void parse_box_tree(mp4_t* m, mp4_track_t* t, const uint8_t* p, int len); +/* Container-box nesting cap. Real files nest a handful deep (moov>trak>mdia> + * minf>stbl); a crafted file can nest thousands deep with 8-byte headers to + * exhaust the demux-thread stack, so bail well before that. */ +#define MP4_MAX_BOX_DEPTH 32 + +static void parse_box_tree(mp4_t* m, mp4_track_t* t, const uint8_t* p, int len, int depth); /* Tracks are selected by role — the first supported video track and the first * supported audio track, wherever they sit in the moov. A fixed first-two-traks * cut would leave a valid file ordered audio,audio,video silently video-less. */ -static void parse_trak(mp4_t* m, const uint8_t* p, int len) { +static void parse_trak(mp4_t* m, const uint8_t* p, int len, int depth) { mp4_track_t t; memset(&t, 0, sizeof(t)); t.nal_len_size = 4; t.timescale = 90000; - parse_box_tree(m, &t, p, len); + parse_box_tree(m, &t, p, len, depth); int have_video = 0, have_audio = 0; for (int i = 0; i < m->ntracks; ++i) { @@ -571,7 +576,8 @@ static void parse_trak(mp4_t* m, const uint8_t* p, int len) { free(t.ctab.stsc); free(t.ctab.chunk_offsets); free(t.ctab.stss); } -static void parse_box_tree(mp4_t* m, mp4_track_t* t, const uint8_t* p, int len) { +static void parse_box_tree(mp4_t* m, mp4_track_t* t, const uint8_t* p, int len, int depth) { + if (depth > MP4_MAX_BOX_DEPTH) return; int off = 0; while (off + 8 <= len) { int sz = (int)rd32(p + off); @@ -580,11 +586,11 @@ static void parse_box_tree(mp4_t* m, mp4_track_t* t, const uint8_t* p, int len) const uint8_t* body = p + off + 8; int blen = sz - 8; switch (ty) { - case 0x7472616b: parse_trak(m, body, blen); break; /* trak */ + case 0x7472616b: parse_trak(m, body, blen, depth + 1); break; /* trak */ case 0x6d646961: /* mdia */ case 0x6d696e66: /* minf */ case 0x65647473: /* edts */ - case 0x7374626c: parse_box_tree(m, t, body, blen); break; /* stbl */ + case 0x7374626c: parse_box_tree(m, t, body, blen, depth + 1); break; /* stbl */ case 0x6d766864: /* mvhd: movie timescale (elst duration units) + duration */ if (blen >= 4) { int ver = body[0]; @@ -1307,7 +1313,7 @@ int basis_mp4_run(basis_media_sink_t* sink, basis_read_fn read, void* ctx, switch (type) { case 0x6d6f6f76: /* moov */ - parse_box_tree(&m, NULL, buf, (int)body); + parse_box_tree(&m, NULL, buf, (int)body, 0); for (int i = 0; i < m.ntracks; ++i) { classic_apply_elst(&m, &m.tracks[i]); classic_init_cursor(&m.tracks[i].ctab); diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_rtmp.c b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_rtmp.c index fbe530f1a7..91fb3f4280 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_rtmp.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_rtmp.c @@ -128,7 +128,7 @@ static int rtmp_read_message(rtmp_t* r) { } if (fmt == 0) { uint8_t sid[4]; if (basis_io_read_full(r->io, sid, 4) != 4) return -1; - c->stream_id = sid[0] | (sid[1]<<8) | (sid[2]<<16) | (sid[3]<<24); + c->stream_id = sid[0] | (sid[1]<<8) | (sid[2]<<16) | ((int)((uint32_t)sid[3]<<24)); } /* Timestamp: a 24-bit field of 0xFFFFFF means the real value (absolute for Type 0, * delta for Type 1/2) follows in a 4-byte extended field — present on this chunk, @@ -244,16 +244,18 @@ static void handle_audio(rtmp_t* r, basis_media_sink_t* sink, chunk_state_t* c) /* find the createStream result's stream id (first AMF number after the command * name "_result" and transaction id). Best-effort. */ static double amf_find_stream_id(const uint8_t* p, int len) { - /* skip command string */ + /* Every multi-byte read is bounds-checked: p/len come straight off the wire, + * so a truncated _result must fall through to the default, not over-read. */ int i = 0; - if (i < len && p[i] == 0x02) { int n=(p[i+1]<<8)|p[i+2]; i += 3 + n; } - /* transaction id (number) */ + /* skip command string: 0x02, u16 len, bytes */ + if (i + 3 <= len && p[i] == 0x02) { int n=(p[i+1]<<8)|p[i+2]; i += 3 + n; } + /* transaction id (number): 0x00 + 8 bytes */ if (i < len && p[i] == 0x00) i += 9; /* command object / null */ if (i < len && p[i] == 0x05) i += 1; - else if (i < len && p[i] == 0x03) { /* skip object */ i++; while (i+2buf; int off = (c->type == 17) ? 1 : 0; - if (c->have > off + 1 && p[off] == 0x02) { + if (c->have > off + 2 && p[off] == 0x02) { int nlen = (p[off+1]<<8)|p[off+2]; const char* name = (const char*)(p + off + 3); /* The first _result is connect's (created==0) and carries no stream id; it * falls through below to send createStream (created=-1). Only the createStream * _result (created==-1) carries the stream id, so extract + play on that one. */ - if (created == -1 && nlen == 7 && strncmp(name, "_result", 7) == 0) { + if (created == -1 && nlen == 7 && off + 3 + 7 <= c->have && + strncmp(name, "_result", 7) == 0) { stream_id = (int)amf_find_stream_id(p + off, c->have - off); created = 1; } diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_rtsp.c b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_rtsp.c index 9a303cfb73..5a5e79e35b 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_rtsp.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_rtsp.c @@ -79,7 +79,9 @@ typedef struct { basis_io_t* io; int cseq; char session[128]; - char authb64[256]; /* Basic auth value, or empty */ + char authb64[344]; /* Basic auth value, or empty. Sized for base64 of the + * longest user:pass the URL can carry (up[256] below): + * 4*ceil(255/3) = 340 chars + NUL. */ char last_status[160]; /* last response status line, for diagnostics */ char www_auth[256]; /* last WWW-Authenticate header value, if any */ char location[1024]; /* last Location header (redirects) */ @@ -309,6 +311,7 @@ typedef struct { * of one AU share an RTP timestamp. */ uint8_t* afrag; int afrag_len, afrag_cap; int afrag_active; /* a reassembly is in flight */ + int afrag_drop; /* frame discarded (over cap): skip its remaining packets */ int64_t afrag_rel; /* base-relative extended ts of the AU */ uint8_t* fu; int fu_len, fu_cap; /* FU reassembly */ @@ -324,17 +327,26 @@ typedef struct { static const uint8_t SC4[4] = {0,0,0,1}; -static int grow(uint8_t** b, int* cap, int need) { +/* Per-AU / per-fragment reassembly ceiling. A server that never sets the RTP + * marker (or streams FU/afrag forever) would otherwise grow these without bound; + * a real assembled frame stays well under this. */ +#define RTP_MAX_BUF (16 * 1024 * 1024) + +static int grow(uint8_t** b, int* cap, int need, int max) { if (need <= *cap) return 1; - int nc = *cap ? *cap * 2 : 65536; + if (need < 0 || need > max) return 0; /* refuse a hostile / overflowed target */ + int64_t nc = *cap ? *cap : 65536; while (nc < need) nc *= 2; + if (nc > max) nc = max; uint8_t* nb = (uint8_t*)realloc(*b, (size_t)nc); if (!nb) return 0; - *b = nb; *cap = nc; return 1; + *b = nb; *cap = (int)nc; return 1; } static void au_append_nal(depkt_t* d, const uint8_t* nal, int len) { - if (!grow(&d->au, &d->au_cap, d->au_len + 4 + len)) return; + /* Over the cap: mark the whole AU dropped so deliver_au discards it rather + * than hand the decoder a partial NAL missing this slice. */ + if (!grow(&d->au, &d->au_cap, d->au_len + 4 + len, RTP_MAX_BUF)) { d->v_drop = 1; return; } memcpy(d->au + d->au_len, SC4, 4); d->au_len += 4; memcpy(d->au + d->au_len, nal, len); d->au_len += len; } @@ -394,7 +406,7 @@ static void depkt_video(depkt_t* d, const uint8_t* rtp, int len) { if (len < 12) return; int cc = rtp[0] & 0x0F; int marker = (rtp[1] >> 7) & 1; - uint32_t ts = (rtp[4] << 24) | (rtp[5] << 16) | (rtp[6] << 8) | rtp[7]; + uint32_t ts = ((uint32_t)rtp[4] << 24) | (rtp[5] << 16) | (rtp[6] << 8) | rtp[7]; int hdr = 12 + cc * 4; if ((rtp[0] & 0x10)) { /* extension */ if (len < hdr + 4) return; @@ -426,15 +438,17 @@ static void depkt_video(depkt_t* d, const uint8_t* rtp, int len) { int s = (p[1] >> 7) & 1, e = (p[1] >> 6) & 1; int otype = p[1] & 0x1F; if (s) { d->fu_active = 1; d->fu_len = 0; d->fu_nal_header0 = (uint8_t)((p[0] & 0xE0) | otype); } - if (d->fu_active && grow(&d->fu, &d->fu_cap, d->fu_len + (plen - 2))) { - memcpy(d->fu + d->fu_len, p + 2, plen - 2); d->fu_len += plen - 2; + if (d->fu_active) { + if (grow(&d->fu, &d->fu_cap, d->fu_len + (plen - 2), RTP_MAX_BUF)) { + memcpy(d->fu + d->fu_len, p + 2, plen - 2); d->fu_len += plen - 2; + } else { d->v_drop = 1; d->fu_active = 0; } /* over cap: drop the AU */ } if (e && d->fu_active) { - if (grow(&d->au, &d->au_cap, d->au_len + 4 + 1 + d->fu_len)) { + if (grow(&d->au, &d->au_cap, d->au_len + 4 + 1 + d->fu_len, RTP_MAX_BUF)) { memcpy(d->au + d->au_len, SC4, 4); d->au_len += 4; d->au[d->au_len++] = d->fu_nal_header0; memcpy(d->au + d->au_len, d->fu, d->fu_len); d->au_len += d->fu_len; - } + } else { d->v_drop = 1; } d->fu_active = 0; } } @@ -458,16 +472,18 @@ static void depkt_video(depkt_t* d, const uint8_t* rtp, int len) { d->fu_nal_header0 = (uint8_t)((p[0] & 0x81) | (otype << 1)); d->fu_nal_header1 = p[1]; } - if (d->fu_active && grow(&d->fu, &d->fu_cap, d->fu_len + (plen - 3))) { - memcpy(d->fu + d->fu_len, p + 3, plen - 3); d->fu_len += plen - 3; + if (d->fu_active) { + if (grow(&d->fu, &d->fu_cap, d->fu_len + (plen - 3), RTP_MAX_BUF)) { + memcpy(d->fu + d->fu_len, p + 3, plen - 3); d->fu_len += plen - 3; + } else { d->v_drop = 1; d->fu_active = 0; } /* over cap: drop the AU */ } if (e && d->fu_active) { - if (grow(&d->au, &d->au_cap, d->au_len + 4 + 2 + d->fu_len)) { + if (grow(&d->au, &d->au_cap, d->au_len + 4 + 2 + d->fu_len, RTP_MAX_BUF)) { memcpy(d->au + d->au_len, SC4, 4); d->au_len += 4; d->au[d->au_len++] = d->fu_nal_header0; d->au[d->au_len++] = d->fu_nal_header1; memcpy(d->au + d->au_len, d->fu, d->fu_len); d->au_len += d->fu_len; - } + } else { d->v_drop = 1; } d->fu_active = 0; } } @@ -479,7 +495,7 @@ static void depkt_video(depkt_t* d, const uint8_t* rtp, int len) { static void depkt_audio(depkt_t* d, const uint8_t* rtp, int len) { if (len < 12 || !d->audio) return; int cc = rtp[0] & 0x0F; - uint32_t ts = (rtp[4] << 24) | (rtp[5] << 16) | (rtp[6] << 8) | rtp[7]; + uint32_t ts = ((uint32_t)rtp[4] << 24) | (rtp[5] << 16) | (rtp[6] << 8) | rtp[7]; int hdr = 12 + cc * 4; if (hdr >= len) return; const uint8_t* p = rtp + hdr; int plen = len - hdr; @@ -508,6 +524,13 @@ static void depkt_audio(depkt_t* d, const uint8_t* rtp, int len) { if (d->afrag_active && rel != d->afrag_rel) { d->afrag_active = 0; d->afrag_len = 0; } int marker = (rtp[1] >> 7) & 1; + /* A frame discarded over the cap keeps dropping its remaining packets (same + * timestamp) so the marker tail isn't emitted as a truncated standalone AU. + * Cleared by the marker (frame end) or a new timestamp. */ + if (d->afrag_drop) { + if (rel != d->afrag_rel) d->afrag_drop = 0; + else { if (marker) d->afrag_drop = 0; return; } + } if (!marker || d->afrag_active) { /* A slice of a fragmented AU (fragments never aggregate: a marker=0 * packet is always one slice). Accumulate; the marker=1 slice @@ -515,9 +538,13 @@ static void depkt_audio(depkt_t* d, const uint8_t* rtp, int len) { int avail = plen - dpos; if (avail <= 0) return; if (!d->afrag_active) { d->afrag_active = 1; d->afrag_len = 0; d->afrag_rel = rel; } - if (grow(&d->afrag, &d->afrag_cap, d->afrag_len + avail)) { + if (grow(&d->afrag, &d->afrag_cap, d->afrag_len + avail, RTP_MAX_BUF)) { memcpy(d->afrag + d->afrag_len, p + dpos, avail); d->afrag_len += avail; + } else { + /* over cap: drop the frame and latch so its tail (incl. the marker + * packet) isn't emitted as a partial. */ + d->afrag_active = 0; d->afrag_len = 0; d->afrag_drop = !marker; return; } if (marker && d->afrag_len > 0) { int64_t pts = rtp_ts_to_us(d->afrag_rel, d->audio->clock ? d->audio->clock : 48000); @@ -879,6 +906,7 @@ static int run_session(basis_media_sink_t* sink, const basis_url_t* url, int use static const char* A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; int o = 0; for (int i = 0; i < n; i += 3) { + if (o > (int)sizeof(r.authb64) - 5) break; /* 4 chars + NUL still fit */ int b0 = (unsigned char)up[i]; int b1 = i + 1 < n ? (unsigned char)up[i + 1] : 0; int b2 = i + 2 < n ? (unsigned char)up[i + 2] : 0; @@ -1084,7 +1112,7 @@ static int run_session(basis_media_sink_t* sink, const basis_url_t* url, int use int channel = hdr[0]; int plen = (hdr[1] << 8) | hdr[2]; if (plen <= 0 || plen > 4 * 1024 * 1024) { rc = -1; break; } - if (!grow(&pkt, &pkt_cap, plen)) { rc = -1; break; } + if (!grow(&pkt, &pkt_cap, plen, RTP_MAX_BUF)) { rc = -1; break; } if (basis_io_read_full(r.io, pkt, plen) != plen) { rc = -1; break; } if (channel == d.v_channel) depkt_video(&d, pkt, plen); diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_ts.c b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_ts.c index 5cb84ef758..609e8cabc0 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_ts.c +++ b/Basis/Packages/com.basis.mediaplayer/Native~/protocol/basis_ts.c @@ -46,13 +46,22 @@ typedef struct { int pkt_size; /* 0 until detected; 188, or 192 for m2ts */ } ts_t; +/* A PES buffer only flushes on the next payload-unit-start for its PID, so a + * stream that sets PUSI once and never again would grow this without bound. Cap + * it: a single video PES (unbounded PES_packet_length, delimited by the next + * PUSI) can legitimately reach a few MiB at high bitrate/4K, well under this. */ +#define TS_MAX_PES (8 * 1024 * 1024) + static int accum_reserve(es_accum_t* e, int extra) { - if (e->len + extra <= e->cap) return 1; - int ncap = e->cap ? e->cap * 2 : 65536; - while (ncap < e->len + extra) ncap *= 2; + int64_t need = (int64_t)e->len + extra; + if (need <= e->cap) return 1; + if (need > TS_MAX_PES) return 0; + int64_t ncap = e->cap ? e->cap : 65536; + while (ncap < need) ncap *= 2; + if (ncap > TS_MAX_PES) ncap = TS_MAX_PES; uint8_t* nb = (uint8_t*)realloc(e->buf, (size_t)ncap); if (!nb) return 0; - e->buf = nb; e->cap = ncap; + e->buf = nb; e->cap = (int)ncap; return 1; } @@ -192,7 +201,7 @@ static void feed_es(ts_t* t, es_accum_t* e, int pusi, const uint8_t* payload, in e->started = 1; } if (!e->started) return; - if (!accum_reserve(e, plen)) return; + if (!accum_reserve(e, plen)) { e->len = 0; e->started = 0; return; } /* over cap: drop, resync on next PUSI */ memcpy(e->buf + e->len, payload, (size_t)plen); e->len += plen; } diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/unity/basis_unity_plugin.cpp b/Basis/Packages/com.basis.mediaplayer/Native~/unity/basis_unity_plugin.cpp index 17420734b7..00053192e2 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/unity/basis_unity_plugin.cpp +++ b/Basis/Packages/com.basis.mediaplayer/Native~/unity/basis_unity_plugin.cpp @@ -170,11 +170,11 @@ UnityPluginUnload() { /* ---- render-thread entry ------------------------------------------------ */ static void BASIS_CALL OnRenderEvent(int event_id, void* data) { - basis_media_engine_t* engine = (basis_media_engine_t*)data; - basis_decoder_t* dec = basis_engine_get_decoder(engine); - if (!dec) return; - if (event_id == BASIS_RENDER_UPDATE) basis_decoder_render_update(dec); - else if (event_id == BASIS_RENDER_RELEASE) basis_decoder_render_release(dec); + /* Forward through the engine's liveness registry: the pointer comes from Unity + * and may already have been freed by basis_media_close on the main thread, so + * the dispatch (and the decoder deref) happens under the registry lock, never + * on a freed engine. */ + basis_engine_render_event((basis_media_engine_t*)data, event_id); } extern "C" BASIS_API basis_render_event_func BASIS_CALL basis_media_get_render_event_func(void) { diff --git a/Basis/Packages/com.basis.mediaplayer/Native~/windows/basis_win_decode.cpp b/Basis/Packages/com.basis.mediaplayer/Native~/windows/basis_win_decode.cpp index 528599151c..0897b1408f 100644 --- a/Basis/Packages/com.basis.mediaplayer/Native~/windows/basis_win_decode.cpp +++ b/Basis/Packages/com.basis.mediaplayer/Native~/windows/basis_win_decode.cpp @@ -842,6 +842,10 @@ static void video_process_to_shared(basis_decoder* d, ID3D11Texture2D* nv12, UIN inView->Release(); } +/* Upper bound on a single decoded output frame — 8K RGB is ~100 MB, so this is + * past any real frame while stopping a malformed cbSize from driving a huge alloc. */ +#define BASIS_MAX_OUTPUT_BUFFER (256u * 1024u * 1024u) + /* Pull all currently-available output samples from the video MFT. * CRITICAL: in DXVA mode the MFT hands us its own IMFSample in outBuf.pSample, * backed by a small pool of D3D11 surfaces. That sample MUST be released every @@ -857,9 +861,25 @@ static void drain_video(basis_decoder* d) { outBuf.dwStreamID = 0; if (!providesSamples) { IMFSample* s = nullptr; IMFMediaBuffer* mb = nullptr; - MFCreateSample(&s); - MFCreateMemoryBuffer(si.cbSize ? si.cbSize : (DWORD)(d->vwidth * d->vheight * 3), &mb); - s->AddBuffer(mb); mb->Release(); + DWORD cb = si.cbSize; + if (!cb) { + /* Dims are attacker-announced; bound each side BEFORE multiplying so + * the product can't overflow (16384 is past any real frame — the SPS + * parser already caps decode dimensions well below this). */ + if (d->vwidth > 0 && d->vwidth <= 16384 && d->vheight > 0 && d->vheight <= 16384) + cb = (DWORD)((uint64_t)d->vwidth * (uint64_t)d->vheight * 3u); + else + cb = 0; + } + /* Cap both the MFT-declared cbSize and the fallback estimate so a + * malformed output size can't exhaust memory. */ + if (cb == 0 || cb > BASIS_MAX_OUTPUT_BUFFER || + FAILED(MFCreateSample(&s)) || FAILED(MFCreateMemoryBuffer(cb, &mb))) { + SAFE_RELEASE(s); SAFE_RELEASE(mb); + break; + } + if (FAILED(s->AddBuffer(mb))) { mb->Release(); s->Release(); break; } + mb->Release(); outBuf.pSample = s; } @@ -1395,16 +1415,29 @@ extern "C" int basis_decoder_set_audio_format(basis_decoder_t* d, basis_codec_t return 0; } +/* Upper bound on a single compressed access unit — far above any real one (an 8K + * HEVC keyframe is a few MB), so a demuxer that ever declared a wild size can't + * drive a huge MFCreateMemoryBuffer allocation. */ +#define BASIS_MAX_INPUT_SAMPLE (64 * 1024 * 1024) + static IMFSample* make_input_sample(const uint8_t* data, int len, int64_t pts_us) { + /* len/data come from the demuxer (attacker-controlled). Reject a wild size and + * return NULL cleanly on a failed allocation rather than dereference a null buffer. */ + if (!data || len <= 0 || len > BASIS_MAX_INPUT_SAMPLE) return nullptr; IMFSample* s = nullptr; IMFMediaBuffer* b = nullptr; - MFCreateSample(&s); - MFCreateMemoryBuffer(len, &b); + if (FAILED(MFCreateSample(&s))) return nullptr; + if (FAILED(MFCreateMemoryBuffer((DWORD)len, &b))) { s->Release(); return nullptr; } BYTE* p = nullptr; DWORD maxlen = 0; - b->Lock(&p, &maxlen, nullptr); - memcpy(p, data, len); - b->Unlock(); - b->SetCurrentLength(len); - s->AddBuffer(b); + HRESULT lhr = b->Lock(&p, &maxlen, nullptr); + if (FAILED(lhr) || !p || maxlen < (DWORD)len) { + if (SUCCEEDED(lhr)) b->Unlock(); /* locked but unusable: unlock before releasing */ + b->Release(); s->Release(); return nullptr; + } + memcpy(p, data, (size_t)len); + if (FAILED(b->Unlock()) || FAILED(b->SetCurrentLength((DWORD)len)) || + FAILED(s->AddBuffer(b))) { + b->Release(); s->Release(); return nullptr; + } s->SetSampleTime((LONGLONG)pts_us * 10); /* us -> 100ns */ b->Release(); return s; @@ -1418,12 +1451,15 @@ static IMFSample* make_input_sample(const uint8_t* data, int len, int64_t pts_us * this; if that ever changes, serialise submission through a decoder mutex. */ extern "C" int basis_decoder_submit_video(basis_decoder_t* d, const uint8_t* annexb, int len, int64_t pts_us, int key) { (void)key; - if (!d || !d->vdec || !annexb || len <= 0) return -1; + /* Bound len here, before the AV1 configOBU concatenation below adds to it — so + * the total can't overflow int or drive an oversized allocation. */ + if (!d || !d->vdec || !annexb || len <= 0 || len > BASIS_MAX_INPUT_SAMPLE) return -1; IMFSample* s; bool carried_config = false; if (d->vConfigObusLen > 0) { /* first AV1 AU: prepend the held configOBUs so the decoder sees the * sequence header before any frame data */ + if (d->vConfigObusLen > BASIS_MAX_INPUT_SAMPLE - len) return -1; /* concat would overflow the cap */ int total = d->vConfigObusLen + len; uint8_t* tmp = (uint8_t*)malloc((size_t)total); if (tmp) { @@ -1438,6 +1474,7 @@ extern "C" int basis_decoder_submit_video(basis_decoder_t* d, const uint8_t* ann } else { s = make_input_sample(annexb, len, pts_us); } + if (!s) return -1; /* sample allocation failed; skip this AU rather than crash */ /* Feed the AU, draining output to make room rather than dropping it. The * decoder must accept every frame or playback decimates to the rate at which @@ -1580,6 +1617,7 @@ extern "C" int basis_decoder_submit_audio(basis_decoder_t* d, const uint8_t* dat if (d->acodec == BASIS_CODEC_OPUS) { submit_opus(d, data, len, pts_us); return 0; } if (!d->adec) return -1; IMFSample* s = make_input_sample(data, len, pts_us); + if (!s) return -1; /* sample allocation failed; skip this frame rather than crash */ HRESULT hr = d->adec->ProcessInput(0, s, 0); s->Release(); if (hr == MF_E_NOTACCEPTING) { drain_audio(d); } diff --git a/Basis/Packages/com.basis.mediaplayer/Plugins/Android/arm64-v8a/libbasis_media_native.so b/Basis/Packages/com.basis.mediaplayer/Plugins/Android/arm64-v8a/libbasis_media_native.so index a9bb2e88fe..c729e404a3 100644 Binary files a/Basis/Packages/com.basis.mediaplayer/Plugins/Android/arm64-v8a/libbasis_media_native.so and b/Basis/Packages/com.basis.mediaplayer/Plugins/Android/arm64-v8a/libbasis_media_native.so differ diff --git a/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll b/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll index f881131ba2..bc9b81c9e2 100644 Binary files a/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll and b/Basis/Packages/com.basis.mediaplayer/Plugins/Windows/x86_64/basis_media_native.dll differ diff --git a/Basis/Packages/com.basis.mediaplayer/Runtime/Core/BasisMediaPlayerSecurity.cs b/Basis/Packages/com.basis.mediaplayer/Runtime/Core/BasisMediaPlayerSecurity.cs index 5e91afe8f4..a977b0796f 100644 --- a/Basis/Packages/com.basis.mediaplayer/Runtime/Core/BasisMediaPlayerSecurity.cs +++ b/Basis/Packages/com.basis.mediaplayer/Runtime/Core/BasisMediaPlayerSecurity.cs @@ -132,6 +132,16 @@ public static bool IsBlockedAddress(IPAddress ip, bool allowLoopback, out string if (b[0] == 169 && b[1] == 254) { reason = "link-local 169.254/16"; return true; } if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) { reason = "RFC1918 172.16/12"; return true; } if (b[0] == 192 && b[1] == 168) { reason = "RFC1918 192.168/16"; return true; } + // IANA "Globally Reachable: False" special-use reserves — non-global-unicast, and may be + // routed to internal infrastructure. Kept in lockstep with the native guard (basis_io.c + // ipv4_octets_blocked) — change both together. + // https://www.iana.org/assignments/iana-ipv4-special-registry/ + if (b[0] == 192 && b[1] == 0 && b[2] == 0) { reason = "IETF protocol 192.0.0/24"; return true; } + if (b[0] == 192 && b[1] == 0 && b[2] == 2) { reason = "TEST-NET-1 192.0.2/24"; return true; } + if (b[0] == 192 && b[1] == 88 && b[2] == 99) { reason = "6to4 relay 192.88.99/24"; return true; } + if (b[0] == 198 && (b[1] & 0xFE) == 18) { reason = "benchmarking 198.18/15"; return true; } + if (b[0] == 198 && b[1] == 51 && b[2] == 100) { reason = "TEST-NET-2 198.51.100/24"; return true; } + if (b[0] == 203 && b[1] == 0 && b[2] == 113) { reason = "TEST-NET-3 203.0.113/24"; return true; } if (b[0] >= 224) { reason = "multicast/reserved >=224/4"; return true; } return false; } diff --git a/Basis/Packages/com.basis.mediaplayer/TESTING.md b/Basis/Packages/com.basis.mediaplayer/TESTING.md index 8c02965bd2..1ee63f1284 100644 --- a/Basis/Packages/com.basis.mediaplayer/TESTING.md +++ b/Basis/Packages/com.basis.mediaplayer/TESTING.md @@ -45,8 +45,8 @@ Things that regularly masquerade as player bugs: `ffmpeg -listen` answer `200` and get treated as **live**. Serve VOD files from nginx (or anything with real range support). -The test-stream stack in [`tools/media-test-streams/`](../../../tools/media-test-streams/) -ships a `preflight.py` that runs these probes across all of its lanes in ~30 seconds. +Script these probes over whatever endpoints you use before a test session — a stalled or +mis-configured feed wastes far more time than the 30 seconds a probe takes. ## Where test streams may live (the security gates) @@ -56,19 +56,31 @@ ships a `preflight.py` that runs these probes across all of its lanes in ~30 sec | Rule | Effect on testing | | --- | --- | | Loopback allowed **in the Editor only** | `localhost` streams work for fast in-editor iteration; the same URL is refused in a build | -| RFC1918 / CGNAT / link-local always blocked | LAN servers (`192.168.*`, `10.*`, …) never work, Editor included — don't bother | -| Hostnames are DNS-validated, fail-closed | A name that resolves to a private address (or doesn't resolve) is refused | -| Scheme allowlist | `http`, `https`, `rtsp`, `rtspt`, `rtmp`, `rtmps`, `rist` — anything else (incl. `file://`) is refused | +| Non-global-unicast addresses always blocked | RFC1918 (`192.168.*`, `10.*`, `172.16-31.*`), CGNAT, loopback, link-local, and the IANA special-use reserves (TEST-NET, benchmarking, 6to4 relay). LAN servers never work, Editor included — don't bother | +| Hostnames are DNS-validated, fail-closed | A name that resolves to any of the above (or doesn't resolve) is refused | +| Scheme allowlist | `http`, `https`, `rtsp`, `rtspt`, `rtmp`, `rtmps`, `rist` — anything else (incl. `file://`) is refused. Passing the gate isn't the same as playable, though: `rtmps` (RTMP-over-TLS) is allowlisted but the player rejects it (use `rtmp://`, or an https fMP4/TS URL), and `rist` only works in the opt-in `-DBASIS_WITH_RIST=ON` build | Practical consequences: -- **Editor iteration:** run the test stack locally with Docker and use `localhost` URLs. -- **Builds, Quest, multi-client tests:** the stream must come from a **public host with real - DNS**. Any cheap VPS running the same stack works. +- **Editor iteration:** point at a public endpoint, or run your own server (RTSP/RTMP/HTTP) locally + and use `localhost` URLs. +- **Builds, Quest, multi-client tests:** the stream must come from a **public host with real DNS** — + a public endpoint below, or your own content on any cheap VPS. - **Quest/Android:** the OS cleartext policy blocks plain `http://` on the JNI fetch path — HTTP-TS and HLS lanes need `https://` with a certificate chain the device actually trusts (serve the full chain; standalone headsets are missing more roots than desktop browsers). `rtsp://` is unaffected. +- **Native local-address re-check (RTSP/RTMP/HLS):** the C# gate above is the first line and is + **not** affected by the env var below — a top-level RFC1918 URL stays refused by C# regardless, + and a top-level `localhost` URL works only in the Editor (the C# rule). Behind it, the native + layer independently re-checks resolved addresses for the transports it opens directly — RTSP/RTMP + (via `basis_io`) and every HLS playlist/segment fetch (the SSRF re-check that stops a hostile + playlist steering a sub-resource URI at an internal host). That native re-check has no Editor + concept, so it refuses `localhost` (and any private address it is handed directly, e.g. an HLS + segment URI the C# gate never saw) unless `BASIS_MEDIA_ALLOW_LOCAL` is set (any non-empty value). + Setting it relaxes **only** that native re-check, not the C# gate — so its practical use is + running your own RTSP/RTMP/HLS server at `localhost` in the Editor. Plain HTTP(S) MP4/TS via the + platform stack (WinHTTP/JNI) has no native re-check and needs no opt-in. - The separate world-content trust allowlist (`BasisDefaultTrustedUrls`, https-only) gates the sandboxed `VideoPlayer` shim path, not this package — but streams hosted on already-trusted domains spare testers a consent prompt when worlds use the same URL. @@ -97,22 +109,36 @@ integration package that provides it — e.g. The same split applies to any future integration: endpoints that need an integration package to function are tested in that package's own TESTING.md. -## What the public internet can't give you: the test-stream stack - -Some lanes have no reliable public endpoint. [`tools/media-test-streams/`](../../../tools/media-test-streams/) -is a Docker Compose stack that provides them, runnable **locally for Editor work** (loopback -is allowed in-editor) and **on any public VPS** for build/Quest/multi-client work: - -- RTSP/RTSPT under your control (including an adversarial long-GOP path for join testing) -- HTTP-TS live feeds -- nginx VOD with real range support -- CEA-608 caption-bearing TS (generated fixture — no public stream carries captions reliably) -- LPCM 5.1/7.1 over M2TS (the full-multichannel lane; AAC on Windows caps at 5.1) -- RIST sender, plain and AES-encrypted (needs the opt-in `-DBASIS_WITH_RIST=ON` plugin build) -- Split-stream video+audio pairs - -Its README covers deployment, asset preparation (bring your own content — real footage with -visible lip-sync moments beats synthetic patterns for A/V sync work), and per-lane URLs. +## Lanes without a public endpoint: bring your own + +The public endpoints above cover the common lanes. The rest — the live transports (RTSP/RTMP/RIST), +split-stream pairs, `localhost` iteration, and a couple of fixtures no public stream carries +reliably (CEA-608 captions, LPCM 7.1 over M2TS) — you provide yourself. There is no bundled +test-server stack to maintain; stand up whatever server you already use and point your own files at +it. + +What the manual pass is actually for: the CI conformance gate (`tools/media-conformance`) already +proves the **demuxers** parse every supported container/codec correctly — on synthetic fixtures, on +every native change. What it cannot touch is real **decode + present** on actual hardware, A/V sync, +and the live network transports. That is exactly what this matrix covers, and it needs real files +and servers. + +Supported inputs to keep on hand (generate with `ffmpeg`, serve however you like): + +- **Containers:** MP4 / fragmented MP4 (`.mp4` `.m4v` `.m4a` `.m4s`), MPEG-TS (`.ts`) and + Blu-ray/AVCHD M2TS (`.m2ts` `.mts`), WebM/Matroska (`.webm`), Ogg (`.opus`), MP3 (`.mp3`), + WAV (`.wav`), HLS (`.m3u8`, TS- or fMP4-segmented). +- **Video codecs:** H.264, H.265/HEVC (`hvc1`), VP9 (WebM and `vp09`-in-MP4), AV1 (progressive and + fragmented MP4, and `V_AV1` WebM). +- **Audio codecs:** AAC (≤ 5.1 on Windows, discrete 5.1 on Android), Opus (WebM and Ogg), MP3 (bare, + and `esds` OTI `0x6B`/`0x69` in MP4), LPCM (WAV, and 7.1 over M2TS). +- **Transports:** any RTSP/RTMP server (e.g. MediaMTX), an `ffmpeg`-served HTTP-TS feed, nginx (or + anything with real `Range`/`206` support) for VOD, an HLS packager, and — for the opt-in + `-DBASIS_WITH_RIST=ON` build — a RIST sender (ffmpeg/librist), plain and AES. + +Two feeder traps worth repeating: a VOD host must answer `206 Partial Content` or `Delivery=Auto` +mis-detects it as live (`python -m http.server` and `ffmpeg -listen` answer `200` — use nginx); and +for A/V-sync work use **real footage with visible lip-sync**, not synthetic patterns. ## The regression matrix @@ -124,50 +150,58 @@ Run the rows your change plausibly touches; run everything before a release-boun | Lane | Source | Verify additionally | | --- | --- | --- | -| RTSP live | VRCDN or stack `rtsp://:8554/main` | Join latency ≈ GOP-bound; pause/resume recovers cleanly. `rtsp://` negotiates UDP transport first and falls back to TCP-interleaved; the Console logs the settled choice once per load (`[NativeMedia] transport: RTSP over UDP`), and it's queryable via `BasisMediaPlayer.CurrentTransport` | -| RTSP adversarial join | stack `rtsp://:8554/slowjoin` | Audio leads video by up to the GOP length on join, then locks — no permanent desync | -| RTSP refusal fallback | stack with `rtspTransports: [tcp]` in `mediamtx.yml` | UDP SETUP is refused (461); playback is indistinguishable from today, no error surfaced; Console logs `RTSP over TCP (UDP unavailable)` | -| RTSP timer fallback | stack with the host's `8000-8001/udp` blocked (or any network that silently eats UDP) | First join stalls ~3 s, then restarts transparently over TCP with the same fallback log line; a reload of the same host skips the probe and goes straight to TCP | +| RTSP live | VRCDN, or your own RTSP server (e.g. MediaMTX) | Join latency ≈ GOP-bound; pause/resume recovers cleanly. `rtsp://` negotiates UDP transport first and falls back to TCP-interleaved; the Console logs the settled choice once per load (`[NativeMedia] transport: RTSP over UDP`), and it's queryable via `BasisMediaPlayer.CurrentTransport` | +| RTSP adversarial join | your own RTSP server fed a long-GOP source | Audio leads video by up to the GOP length on join, then locks — no permanent desync | +| RTSP refusal fallback | your own RTSP server configured TCP-only (`rtspTransports: [tcp]` in MediaMTX) | UDP SETUP is refused (461); playback is indistinguishable from today, no error surfaced; Console logs `RTSP over TCP (UDP unavailable)` | +| RTSP timer fallback | any network that silently drops the RTP UDP ports (`8000-8001/udp`) | First join stalls ~3 s, then restarts transparently over TCP with the same fallback log line; a reload of the same host skips the probe and goes straight to TCP | | RTSP forced TCP | `rtspt://` form of any RTSP URL | No UDP attempt at all (no UDP `SETUP` in the server log); Console logs `RTSP over TCP` | -| HTTP-TS live | VRCDN `.live.ts` or stack | Same checks over plain TS; on Quest use the https lane | -| HLS VOD | Mux master or stack packaging | Variant switch via panel bitrate dropdown mid-play | +| HTTP-TS live | VRCDN `.live.ts`, or your own `ffmpeg`-served TS | Same checks over plain TS; on Quest use the https lane | +| HLS VOD | Mux master, or your own HLS packaging | Variant switch via panel bitrate dropdown mid-play | | Progressive/fMP4 MP4 | Big Buck Bunny | `Delivery=Auto` detects OnDemand (needs the 206); seek slider works | -| RTMP | stack `rtmp://:1935/main` | Minimal client — plain `rtmp://` pull only | -| RIST plain + AES | stack (RIST profile) | Requires RIST-enabled plugin build; loss recovery under induced packet loss | -| WAV audio-only | stack VOD | 16/24-bit, up to 8 ch; no video track is not an error | -| Split-stream | stack pair | Windows-only today; `AudioUri` lane syncs to video | +| RTMP | your own RTMP server (e.g. MediaMTX) | Minimal client — plain `rtmp://` pull only | +| RIST plain + AES | your own RIST sender (ffmpeg/librist) | Requires RIST-enabled plugin build; loss recovery under induced packet loss | +| WAV audio-only | your own WAV over HTTP | 16/24-bit, up to 8 ch; no video track is not an error | +| Split-stream | your own video-only + audio-only pair | Windows-only today; `AudioUri` lane syncs to video | ### Content and codecs +No public host carries every codec in every container flavour, so generate these from a CC +clip — Big Buck Bunny (full URL in the endpoints table above) or any Blender open movie — with +the `ffmpeg` recipe in each row. `in.mp4` below is that source clip. For higher-res / 4K masters, +[media.xiph.org](https://media.xiph.org/) mirrors the Blender films losslessly (e.g. Sintel 4K at +`https://media.xiph.org/sintel/sintel-4k.y4m.xz`, and `sintel-4k-png/` frame sets) — grab one and +cut a short segment (`ffmpeg -i sintel-4k.y4m -t 20 -c copy in4k.y4m`). (The demux side is already +covered bit-for-bit by the CI conformance gate; these rows are the real decode + present pass.) + | Fixture | Verify | | --- | --- | | H.264 + AAC stereo | The baseline — everything else assumes this passes | -| H.265/HEVC | **Video actually appears** (`https://mr.town/vod/tos_hevc.mp4`, `hvc1` from stock libx265 — what most HEVC in the wild is). Check for frames, not for the absence of an error: `hvc1` keeps its parameter sets only in the `hvcC` box, so anything that loses them on the way to the decoder gives a black screen with no error raised and nothing in the Console. Absence of the codec is the other half of the row — without the HEVC Video Extension installed it must degrade cleanly, and testing only that half will pass while playback is comprehensively broken | -| VP9 in WebM (`https://mr.town/vod/tos_vp9.webm`) | Plays on Windows (Store "VP9 Video Extensions" + a GPU with hardware VP9 — the probe gates both) and Quest (hardware everywhere). The fixture is a two-pass encode carrying superframes, so whole-superframe feeding is exercised by playing it | -| VP9 in MP4 (`https://mr.town/vod/tos_vp9.mp4`) | The `vp09` sample-entry lane; same decode path as WebM | -| AV1 in progressive MP4 (`https://mr.town/vod/drip.mp4`) | Plays with video on Windows (Store "AV1 Video Extension" + a GPU with hardware AV1 — RTX 30+/RX 6000+/Arc; the probe gates both) and Quest 3. This file historically misplayed as silent audio-only | -| AV1 in fragmented MP4 (`https://mr.town/vod/tos_av1_frag.mp4`) | The `av1C`-in-`stsd` fMP4 walk with the configOBU first-AU prepend | -| AV1 4K (`https://mr.town/vod/bbb_av1_4k.mp4`) | 2160p decode + ring memory on both platforms | -| AV1 in WebM (`https://mr.town/vod/tos_av1.webm`) | The `V_AV1` CodecID lane (CodecPrivate = av1C record → configOBU extradata); duration + Cues seek work as for VP9 | +| H.265/HEVC | **Video actually appears** (`ffmpeg -i in.mp4 -c:v libx265 -tag:v hvc1 -c:a aac hevc.mp4` — `hvc1` from stock libx265, what most HEVC in the wild is). Check for frames, not for the absence of an error: `hvc1` keeps its parameter sets only in the `hvcC` box, so anything that loses them on the way to the decoder gives a black screen with no error raised and nothing in the Console. Absence of the codec is the other half of the row — without the HEVC Video Extension installed it must degrade cleanly, and testing only that half will pass while playback is comprehensively broken | +| VP9 in WebM (`ffmpeg -i in.mp4 -c:v libvpx-vp9 -b:v 0 -crf 32 -c:a libopus vp9.webm`; two-pass for superframes) | Plays on Windows (Store "VP9 Video Extensions" + a GPU with hardware VP9 — the probe gates both) and Quest (hardware everywhere). A two-pass encode carries superframes, so whole-superframe feeding is exercised by playing it | +| VP9 in MP4 (`ffmpeg -i in.mp4 -c:v libvpx-vp9 -c:a aac vp9.mp4`; modern ffmpeg writes the `vp09` sample entry) | The `vp09` sample-entry lane; same decode path as WebM | +| AV1 in progressive MP4 (`ffmpeg -i in.mp4 -c:v libaom-av1 -crf 30 -c:a aac av1.mp4`) | Plays with video on Windows (Store "AV1 Video Extension" + a GPU with hardware AV1 — RTX 30+/RX 6000+/Arc; the probe gates both) and Quest 3. AV1-in-MP4 historically misplayed as silent audio-only | +| AV1 in fragmented MP4 (the AV1 MP4 recipe + `-movflags frag_keyframe+empty_moov`) | The `av1C`-in-`stsd` fMP4 walk with the configOBU first-AU prepend | +| AV1 4K (a 2160p slice of Sintel 4K — see the intro above — through the AV1 MP4 recipe) | 2160p decode + ring memory on both platforms | +| AV1 in WebM (the AV1 recipe with `av1.webm`) | The `V_AV1` CodecID lane (CodecPrivate = av1C record → configOBU extradata); duration + Cues seek work as for VP9 | | AV1 extension absent (Windows) | Uninstall/absent "AV1 Video Extension": a direct `av01` URL errors with the install hint, and the probe answers 0 so the resolver never offers AV1 | | AV1 on Quest 2 | No AV1 decoder on the device: a direct `av01` URL refuses cleanly, and YouTube resolution still succeeds via the VP9 lane (its probe passes there) | -| Opus in muxed WebM (`https://mr.town/vod/tos_vp9_opus.webm`) | VP9 video + Opus audio in one file: plays whole with audio on Windows and Quest. Exercises the two-track WebM demux (blocks routed to video vs audio by TrackNumber) | -| Opus audio-only WebM (`https://mr.town/vod/tos_opus.webm`) | An `A_OPUS`-only WebM (YouTube's audio itags 249/250/251): audio plays with no video, driven by the audio-only contract | +| Opus in muxed WebM (`ffmpeg -i in.mp4 -c:v libvpx-vp9 -c:a libopus vp9_opus.webm`) | VP9 video + Opus audio in one file: plays whole with audio on Windows and Quest. Exercises the two-track WebM demux (blocks routed to video vs audio by TrackNumber) | +| Opus audio-only WebM (`ffmpeg -i in.mp4 -vn -c:a libopus opus.webm`) | An `A_OPUS`-only WebM (YouTube's audio itags 249/250/251): audio plays with no video, driven by the audio-only contract | | Opus decode on Windows | Native via the libopus that `com.avionblock.opussharp` ships, runtime-loaded (no Store extension, unlike VP9/AV1). Confirm audio plays in the Editor (the library resolves from the opussharp `Packages/…` path) and in a build (`opus.dll` flattened beside the plugin). If opussharp is absent the format is refused: muted audio, video unaffected, never a crash | | Opus on Quest | Native `audio/opus` MediaCodec with OpusHead + pre-skip/pre-roll csd; gapless start sane, audio-only path works | | Ogg Opus file (`.opus`) | A `.opus` URL routes as directly-playable (no resolver) and plays: the Ogg demuxer walks pages/lacing, verifies each page CRC, reads OpusHead, and feeds the same Opus decoder. A `.opus` with a damaged page resyncs on the next `OggS` rather than failing | | Ogg Opus seek (`.opus`) | On a range/`206` host, a `.opus` file reports its duration (a seek bar appears) and seeks — Ogg has no index, so seek is granule bisection over the byte range; it lands at page granularity near the target and resumes. A live/no-range source has no seek bar (duration 0), which is correct. Check the Editor (Windows) | -| Unsupported video codec | `https://mr.town/vod/tos_vp8.webm` and `https://mr.town/vod/tos_mp4v.mp4` refuse with a clear "video codec 'x' is not supported" error naming the codec — never silent audio under a black screen | +| Unsupported video codec | VP8 (`ffmpeg -i in.mp4 -c:v libvpx vp8.webm`) and MPEG-4 Part 2 (`ffmpeg -i in.mp4 -c:v mpeg4 mp4v.mp4`) refuse with a clear "video codec 'x' is not supported" error naming the codec — never silent audio under a black screen | | VP9/AV1 software-fallback guard | On a GPU without hardware decode for the profile, a direct VP9/AV1 URL must produce the "video decoder produced software frames" error, not a black screen (the Store MFTs silently fall back to CPU — for AV1 that is the *majority* of pre-RTX-30 desktops; only reproducible on a no-hw box or with the extension's fallback forced) | | AAC decoder priming | Audio starts on the first real sample, not on the decoder's priming. AAC's encoder delay is one 1024-sample frame, which MP4 signals with an edit list (`elst media_time=1024` on anything `ffmpeg -c:a aac` produced); the samples ahead of that origin must not reach the output. **Do not try to hear this** — 21 ms of lag is below the lip-sync threshold, which is exactly why it went unnoticed for so long. Measure it: decode the file with `ffmpeg -i x.m4a -map a:0 -f f32le -acodec pcm_f32le ref.f32`, capture what the player served, and cross-correlate. Assert on the **peak's sample offset**, not a correlation value: aligned output peaks at offset 0, a stream still carrying its priming peaks at offset 1024 (the edit-list delay) — the actual defect, and reliable regardless of content, channels, or capture. (The absolute coefficient at offset 0 is content-dependent — a shifted stream reads roughly -0.07 on this fixture, but do not gate on that number.) An LPCM/WAV file is the control — no decoder, no priming, peaks at offset 0 | | AAC 5.1 | Windows MF decodes ≤ 5.1; correct channel mapping (use content with known channel placement, judge by ear per output speaker) | -| AAC 5.1 in a progressive MP4 (Android) | Decodes to discrete 5.1, not silence. The esds can carry an inert SBR sync extension the Android decoder otherwise rejects (`aacDecoder 0x1001` in logcat); fixture `https://mr.town/vod/scope.mp4` | +| AAC 5.1 in a progressive MP4 (Android) | Decodes to discrete 5.1, not silence. Generate a 5.1 AAC MP4 (`ffmpeg -i in.mp4 -c:a aac -ac 6 aac51.mp4`). The esds can carry an inert SBR sync extension the Android decoder otherwise rejects (`aacDecoder 0x1001` in logcat) — that extension is encoder-dependent, so use a clip that carries it when chasing that path | | MP3 bare stream (`.mp3`) | CBR and VBR play forward; a leading `ID3v2` tag is skipped and a Xing/Info/VBRI header frame is dropped (not heard as a click). Duration is reported from the header's frame count and the seek slider works. Windows uses the in-box Media Foundation MP3 decoder, Quest the `audio/mpeg` MediaCodec. Generate fixtures with `ffmpeg -i src.wav -c:a libmp3lame -b:a 192k cbr.mp3` and `-q:a 2 vbr.mp3` | | MP3 in MP4/M4A | An `mp4a` sample entry whose `esds` object-type-indication is `0x6B`/`0x69` plays as MP3, not misdetected as AAC (`ffmpeg -i cbr.mp3 -c copy out.m4a`) | | LPCM 7.1 M2TS | All 8 lanes audible and correctly placed — the only full-7.1 path on Windows | | PCE-signalled / >6-ch AAC | **Graceful refusal** on Windows (mute or clean error, never a crash) | | Trailing-moov progressive MP4 | Non-faststart file (`ffmpeg -i in.mp4 -c copy out.mp4` leaves `moov` after `mdat`): on a range/`206` server it plays with seek + duration; over a one-way stream (no ranges) it refuses cleanly with a faststart-remux hint | -| CEA-608 captions | Stack caption fixture: cues appear on time, accented characters correct, clear-cue clears, CC toggle + opacity sliders live-apply | +| CEA-608 captions | A caption-bearing TS you generate (no public stream carries captions reliably): cues appear on time, accented characters correct, clear-cue clears, CC toggle + opacity sliders live-apply | | 44.1 kHz audio | Resamples cleanly to the DSP rate (dominant path is 48 kHz — don't let 44.1k rot) | | Non-16-aligned coded height | No pad strip on the video edge (a thin top strip on Windows, a grey bottom strip on Android) and the RenderTexture matches the display aspect. 720p and other 16-aligned heights are clean, so test a padded height specifically — 1080p (→1088) on Windows, 640×360 (→368) on Android | @@ -200,8 +234,9 @@ the demux leg re-anchors delivery pacing at the flushed boundary, so a mis-ancho Editor (Windows) and Quest. **Seek (integrated fMP4)** — on a self-contained fragmented MP4 (moof/mdat fragments indexed by a -`sidx`) served from a range/`206` host — e.g. -`https://zipline.space.superneko.net/raw/bbb_sunflower_1080p_30fps_normal_idfmp4.mp4` — confirm +top-level `sidx`) served from a range/`206` host. Produce one from a CC clip: +`ffmpeg -i in.mp4 -c copy -movflags +frag_keyframe+empty_moov+global_sidx out.mp4` (the `global_sidx` +box is what the byte-source seek indexes). Confirm `Delivery=Auto` detects OnDemand and seeks in both directions reposition cleanly and resume at the target with no decoder error. This is the `sidx`-driven byte-source reseek; it shares the byte-source seek path with progressive/trailing-moov MP4, so a regression here usually surfaces on @@ -214,12 +249,13 @@ can't resynchronise the box parser. Check the Editor (Windows) and Quest. > MP4, and integrated fMP4 qualify; a live source can't seek, so those clients converge > independently to the live edge rather than using playhead-seek correction. -**Seek (WebM Cues)** — on `https://mr.town/vod/tos_vp9.webm` and the trailing-Cues variant, -seek both directions: playback lands at or just before the target (cue/cluster granularity, on -a keyframe) and resumes paced at 1x — the same stall-forward / flood-backward failure shapes as -the HLS row apply. Seek near the very end of the file as well (EOS race). The cueless variant -must show no seek bar at all. `https://mr.town/vod/tos_av1.webm` rides the same cue walk with -the AV1 branch — one both-directions pass there covers it. Check the Editor (Windows) and Quest. +**Seek (WebM Cues)** — on your VP9 WebM fixture (the codec-row recipe; a `libvpx-vp9` encode +carries Cues) served from a range/`206` host, seek both directions: playback lands at or just +before the target (cue/cluster granularity, on a keyframe) and resumes paced at 1x — the same +stall-forward / flood-backward failure shapes as the HLS row apply. Seek near the very end of the +file as well (EOS race). A cueless variant (`-cues_to_front 0`, or strip the Cues) must show no +seek bar at all. Your AV1 WebM fixture rides the same cue walk with the AV1 branch — one +both-directions pass there covers it. Check the Editor (Windows) and Quest. **Seek (MP3)** — on a `.mp3` VOD over a range/`206` server, seek both directions and near the end. MP3 seek is inherently approximate (no per-frame timestamps): CBR lands within a frame via @@ -241,13 +277,28 @@ be entirely absent). Controls that don't apply to the loaded media should be abs not broken. **Security gates** — negative tests matter: `http://192.168.1.10/x.ts` must refuse with a -clear reason on every platform; `localhost` must refuse **in a build** (and work in the -Editor); `file:///` must refuse. A regression that *opens* a gate is a security bug — -flag it as such, not as a playback bug. - -**A/V sync judgement** — use real footage with visible speech; synthetic patterns hide sync -drift. Watch a full minute at the live edge, not five seconds. For anything subtle, capture -diagnostics (below) rather than trusting perception. +clear reason on every platform (that RFC1918 refusal is the C# gate and holds regardless of any +env var); a plain HTTP(S) `localhost` MP4/TS URL must refuse **in a build** and work in the +Editor; `file:///` must refuse. On the native-transport lanes (HLS, RTSP, RTMP) even the Editor +`localhost` case is refused unless `BASIS_MEDIA_ALLOW_LOCAL` relaxes the native re-check (see the +security-gates section above) — a refusal there without the opt-in is correct, not a regression. +A regression that *opens* a gate is a security bug — flag it as such, not as a playback bug. + +**HLS sub-resource SSRF** — the URL gate only sees the top-level playlist, so the native +source re-checks each URI a playlist steers it to. Serve a media `.m3u8` from a public host +whose segment (or `EXT-X-MAP`, or a nested variant) URI is an absolute +`http://169.254.169.254/…` / `http://192.168.x.x/…` / `http://127.0.0.1:PORT/…`: playback must +fail rather than issue that fetch (watch the target server's logs — the internal host must see no +request). A playlist that reaches an internal host is a security regression, not a broken-stream +bug. (Editor testing of the legitimate localhost lane needs `BASIS_MEDIA_ALLOW_LOCAL` — see the +security-gates section above.) + +**A/V sync judgement** — use real footage with **visible speech**; synthetic patterns hide sync +drift, and Big Buck Bunny (the baseline endpoint) has no dialogue at all. A CC-BY Blender open +movie with clear lip-sync is a good source — Sintel and Spring both work; download from +[Blender Studio films](https://studio.blender.org/films/) and re-encode/serve as needed. Watch a +full minute at the live edge, not five seconds. For anything subtle, capture diagnostics (below) +rather than trusting perception. Know what this row cannot do, and do not treat audibility as the pass bar. A fixed offset below roughly 45 ms is still a real A/V-sync regression — it is just below the threshold where @@ -280,20 +331,20 @@ with on-screen text or a logo, every time video-path code changes. A report that can be acted on contains: -1. The exact URL (or the stack lane + asset recipe) — full URL, not a fragment +1. The exact URL (or how to reproduce the source — server + asset recipe) — full URL, not a fragment 2. Platform, graphics API, Editor-or-build, headset if relevant 3. What was expected, what happened, and how reliably it reproduces 4. Console output around the failure (the `Video`-tagged lines) and, for timing/sync issues, the diagnostics CSV covering the incident -5. Whether the preflight/ffprobe of the same URL was green at the time +5. Whether `ffprobe` of the same URL was green at the time ## Acknowledgements The always-on live lanes above are [VRCDN](https://vrcdn.live/)'s own public channel, listed here with their permission — thanks to the VRCDN team for keeping a reliable 24/7 reference stream running and for letting this guide point testers at it. Be a good guest: use it for -interactive test sessions, not automated soak loops, and stand up the self-hosted stack for -anything sustained. +interactive test sessions, not automated soak loops, and stand up your own server for anything +sustained. ## Native plugin changes: the security boundary @@ -340,7 +391,14 @@ crash, hang, or corrupt does. these and how you prove one is gone. An unsanitised "it didn't crash this time" is not proof. Fuzzing corrupt input is the single highest-value test this code has; a parser change that ships without a fuzz pass is under-tested. When you add a parser, add a `fuzz_.c` target - beside the others. + beside the others. Targets exist for the container demuxers (TS/MP4/WebM/Ogg/MP3), the caption + scanner, the URL parser (`fuzz_url`), the HLS playlist source (`fuzz_hls`), and the RTSP/RTMP + parsers (`fuzz_rtsp`/`fuzz_rtmp` — their harness `#include`s the real `.c` and stubs `basis_io`, + byte-serving the read paths; `parse_sdp`/`depkt_*`/`amf_*`/FLV tag parsers are driven directly). + Deeper full-session coverage (a scripted handshake or an injected transport vtable) is a documented + follow-up. Still exercise all of these with the adversarial live-server rows above + (truncated/oversized headers, a server that never sets the RTP marker) — fuzzing complements the + matrix, it doesn't replace it. - **Keep every crash's repro as a permanent fixture.** When a malformed stream is found to crash, the exact file that triggered it is pinned under `tools/media-fuzz/testcases/` and replayed by the `fuzz-demux` CI job (`media-native.yml`) on every native change — a fixed diff --git a/tools/media-fuzz/README.md b/tools/media-fuzz/README.md index 47b2eefaac..348b80d317 100644 --- a/tools/media-fuzz/README.md +++ b/tools/media-fuzz/README.md @@ -45,7 +45,29 @@ the crash; if the minimized file stops reproducing, keep the original artifact. | `fuzz_mp4` | `basis_mp4_run` — MP4/fMP4 box + sample-table demux | `basis_mp4.c` + `basis_bitstream.c` + `basis_caption.c` | | `fuzz_webm` | `basis_webm_run` — WebM/Matroska EBML demux | `basis_webm.c` + `basis_bitstream.c` | | `fuzz_ogg` | `basis_ogg_run` — Ogg page/lacing/CRC demux (`.opus`) | `basis_ogg.c` | +| `fuzz_mp3` | `basis_mp3_run` — MP3 frame/Xing/VBRI demux | `basis_mp3.c` | | `fuzz_caption` | `basis_caption_scan_au` — in-band CEA-608 SEI scan | `basis_caption.c` + `basis_bitstream.c` | +| `fuzz_url` | `basis_url_parse` — scheme/userinfo/host/port/path split | `basis_url.c` | +| `fuzz_hls` | `basis_hls_*` — M3U8 master/media parse, URI resolve, segment stitch, seek/reposition | `basis_hls.c` + `basis_url.c` | +| `fuzz_rtsp` | `parse_sdp` + `depkt_video`/`depkt_audio` (RTP FU/AP/afrag reassembly) + `rtsp_recv` | `basis_rtsp.c` (via `#include`) + `basis_bitstream.c` | +| `fuzz_rtmp` | `amf_find_stream_id` + `handle_video`/`handle_audio` (FLV) + `rtmp_read_message` (chunk assembler) | `basis_rtmp.c` (via `#include`) + `basis_bitstream.c` | + +`fuzz_hls` injects an in-memory HTTP provider (the fuzz bytes are the body of every fetched +URL — playlist and segments), so no network is touched; it stubs `basis_io_host_is_blocked` +to always-allow so playlist parsing is actually reached (the real SSRF host check resolves DNS +and is exercised at runtime, not in-process — its URL-parsing half is covered by `fuzz_url`). +`basis_hls.c` spawns a producer thread, so `build.sh` links `-pthread` off-Windows. + +**RTSP/RTMP.** These own their sockets, so their harness `#include`s the real `basis_rtsp.c` / +`basis_rtmp.c` (statics become reachable, and a find is still a find in the shipping parser) and +provides a link-time `basis_io` stub — no-op for the write paths, byte-serving for the read paths +(`rtsp_recv`, `rtmp_read_message`). The buffer-taking parsers (`parse_sdp`, `depkt_video`/`_audio`, +`amf_find_stream_id`, `handle_video`/`_audio`) are called directly with no handshake to script, so +they hit the exact code where review found the H1/M2 OOBs. This first run found a **signed left-shift +UB** in the RTP timestamp read (`rtp[4] << 24` on a byte ≥ 128, in both `depkt_video`/`depkt_audio`, +plus the same in the RTMP chunk stream id) — fixed and pinned as `testcases/rtsp/rtp_ts_shift_ub.bin`. +A full-session seam (scripted handshake, or an injected transport vtable that would also enable +RTSP/RTMP unit tests) is the remaining depth work — tracked in the media-player backlog as B76. New targets slot in the same way — one `fuzz_.c` driver plus its protocol sources in `build.sh`. The MP4 and WebM drivers add a `reseek` callback (both are offset-driven — MP4's `moov` @@ -76,3 +98,9 @@ fix and guards against reintroduction. Replay one with `build/fuzz_.exe te - `ts/pat_pmt_section_len_oob.ts` — out-of-bounds read in `parse_pat`/`parse_pmt`: the 12-bit `section_len` (and PMT `prog_info_len`) is trusted and walked up to ~4 KB past the ~184-byte TS payload, running off the demux buffer when the section packet is the last one buffered. +- `rtsp/rtp_ts_shift_ub.bin` — signed left-shift UB in the RTP timestamp read: `rtp[4] << 24` + shifted a byte ≥ 128 into the `int` sign bit (`depkt_video`/`depkt_audio`). A 12-byte RTP + header with `byte[4] = 0xFF` trips it; fixed by reading the timestamp through `uint32_t`. +- `rtmp/chunk_streamid_shift_ub.bin` — the same signed-shift UB in the RTMP chunk stream id + (`sid[3] << 24`, `rtmp_read_message`), which the RTP repro above doesn't reach. A 12-byte fmt-0 + chunk header with the stream-id MSB set trips it; fixed by shifting through `uint32_t`. diff --git a/tools/media-fuzz/build.sh b/tools/media-fuzz/build.sh index 5f1dcb8bc1..4cfaec40c4 100755 --- a/tools/media-fuzz/build.sh +++ b/tools/media-fuzz/build.sh @@ -35,8 +35,8 @@ build_target() { want="${1:-all}" case "$want" in - all|ts|mp4|webm|caption|ogg|mp3) ;; - *) echo "unknown fuzz target: $want (expected: all ts mp4 webm caption ogg mp3)" >&2; exit 2 ;; + all|ts|mp4|webm|caption|ogg|mp3|url|hls|rtsp|rtmp) ;; + *) echo "unknown fuzz target: $want (expected: all ts mp4 webm caption ogg mp3 url hls rtsp rtmp)" >&2; exit 2 ;; esac if [ "$want" = "all" ] || [ "$want" = "ts" ]; then build_target ts \ @@ -68,6 +68,35 @@ if [ "$want" = "all" ] || [ "$want" = "mp3" ]; then build_target mp3 \ "$native/protocol/basis_mp3.c" fi +if [ "$want" = "all" ] || [ "$want" = "url" ]; then + build_target url \ + "$native/protocol/basis_url.c" +fi +if [ "$want" = "all" ] || [ "$want" = "hls" ]; then + # basis_hls.c spawns a producer thread; link pthread off-Windows (Win32 threads + # auto-link kernel32). The SSRF host check is stubbed in the harness, so + # basis_io.c and its socket libraries aren't needed. + hls_extra="" + case "$(uname -s 2>/dev/null || echo unknown)" in + MINGW*|MSYS*|CYGWIN*) ;; + *) hls_extra="-pthread" ;; + esac + build_target hls \ + "$native/protocol/basis_hls.c" \ + "$native/protocol/basis_url.c" \ + $hls_extra +fi +# rtsp/rtmp own their sockets, so their harness #includes the real protocol .c and +# provides a basis_io stub (byte-serving for the read paths). Only basis_bitstream +# is linked in — the protocol .c comes via the #include, not the command line. +if [ "$want" = "all" ] || [ "$want" = "rtsp" ]; then + build_target rtsp \ + "$native/protocol/basis_bitstream.c" +fi +if [ "$want" = "all" ] || [ "$want" = "rtmp" ]; then + build_target rtmp \ + "$native/protocol/basis_bitstream.c" +fi # On Windows the ASan runtime is a DLL that must sit next to the exe (or on # PATH) at run time. On Linux it is statically linked; nothing to copy. diff --git a/tools/media-fuzz/native/fuzz_hls.c b/tools/media-fuzz/native/fuzz_hls.c new file mode 100644 index 0000000000..aa796ae7cd --- /dev/null +++ b/tools/media-fuzz/native/fuzz_hls.c @@ -0,0 +1,82 @@ +/* + * fuzz_hls - libFuzzer target for the HLS playlist source (basis_hls_*). + * + * basis_hls.c parses attacker-controlled M3U8 text (master + media playlists, + * EXT-X tags, segment/variant/map URIs) and stitches the referenced segments + * into one byte stream. It runs on a peer-broadcast URL, so a playlist that + * miscounts an attribute, over-copies a URI, or mishandles the seek/reposition + * read path must fault here under ASan/UBSan, not on a client. + * + * The transport is injected: an in-memory provider serves the fuzzer's bytes as + * the body of every fetched URL (the playlist and every segment), so no network + * is touched. The SSRF host check is stubbed out below so parsing is actually + * reached — that guard resolves DNS and is exercised at runtime, not here; the + * URL-parsing half of it is covered by fuzz_url. + * + * Build: see ../build.sh (clang -fsanitize=fuzzer,address,undefined). + */ +#include +#include +#include +#include + +#include "basis_media_internal.h" /* BASIS_READ_REPOSITION */ +#include "protocol/basis_hls.h" + +/* Stub the SSRF resolver so the HLS parser is isolated from DNS/sockets (and the + * fuzz build needn't link basis_io.c). Always "allowed" -> parsing proceeds. */ +int basis_io_host_is_blocked(const char* host) { (void)host; return 0; } + +/* One in-memory response = the whole fuzz buffer, replayed per open(). */ +static const uint8_t* g_data; +static size_t g_size; + +typedef struct { size_t pos; } mem_ctx; + +static void* mem_open(const char* url) { (void)url; return calloc(1, sizeof(mem_ctx)); } +static void mem_close(void* ctx) { free(ctx); } +static int mem_read(void* ctx, uint8_t* buf, int len) { + mem_ctx* c = (mem_ctx*)ctx; + if (!c || len <= 0) return 0; + size_t avail = g_size - c->pos; + size_t take = (size_t)len < avail ? (size_t)len : avail; + if (take) { memcpy(buf, g_data + c->pos, take); c->pos += take; } + return (int)take; +} + +/* Bound the producer's blocking reloads/retries so a crafted "live" playlist + * can't spin and drag down fuzz throughput; libFuzzer -timeout is the outer + * backstop. Generous enough to stitch a multi-segment playlist, far below the + * old 100k that let a non-advancing playlist burn ~100k callbacks per input. */ +enum { HLS_MAX_POLL_CALLBACKS = 4096 }; +static int g_poll; +static int keep_running(void* user) { (void)user; return (++g_poll) <= HLS_MAX_POLL_CALLBACKS; } + +int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + g_data = data; g_size = size; g_poll = 0; + + basis_http_provider_t provider = { mem_open, mem_read, mem_close }; + int is_fmp4 = 0; + void* hls = basis_hls_open("http://fuzz.invalid/index.m3u8", &provider, + keep_running, NULL, &is_fmp4); + if (!hls) return 0; + + /* Touch the metadata getters and drain the stitched stream so segment + * stitching and the demuxer-facing read path are exercised. */ + (void)basis_hls_is_vod(hls); + (void)basis_hls_duration_ms(hls); + if (basis_hls_can_seek(hls) && size) basis_hls_request_seek(hls, (long long)(data[0]) * 100); + + uint8_t buf[4096]; + volatile uint8_t sink = 0; + for (int i = 0; i < 2048; ++i) { + int n = basis_hls_read(hls, buf, (int)sizeof(buf)); + if (n == BASIS_READ_REPOSITION) continue; + if (n <= 0) break; + for (int k = 0; k < n; ++k) sink ^= buf[k]; /* force ASan to see the bytes */ + } + (void)sink; + + basis_hls_close(hls); + return 0; +} diff --git a/tools/media-fuzz/native/fuzz_rtmp.c b/tools/media-fuzz/native/fuzz_rtmp.c new file mode 100644 index 0000000000..d743c2c431 --- /dev/null +++ b/tools/media-fuzz/native/fuzz_rtmp.c @@ -0,0 +1,107 @@ +/* + * fuzz_rtmp - libFuzzer target for the RTMP chunk / AMF / FLV parsers. + * + * Like fuzz_rtsp, RTMP owns its socket, so this compiles the real basis_rtmp.c in + * (via #include) and reaches its parsers directly: + * - amf_find_stream_id (server _result AMF walk — the M2 bug lived here); + * - handle_video / handle_audio (FLV tag -> AVC/AAC extraction) on a synthetic + * chunk; + * - rtmp_read_message (the chunk-header assembler) fed the fuzz bytes through a + * link-time basis_io stub in place of a socket. + * + * Build: see ../build.sh (clang -fsanitize=fuzzer,address,undefined). + */ +#include +#include +#include +#include + +#include "basis_media_internal.h" +#include "protocol/basis_io.h" + +static const uint8_t* g_data; +static size_t g_size; +static size_t g_pos; + +/* ---- basis_io stub: serve fuzz bytes; the rest are no-ops for the link ----- */ +int basis_io_read_full(basis_io_t* io, uint8_t* buf, int len) { + (void)io; + int got = 0; + while (got < len && g_pos < g_size) buf[got++] = g_data[g_pos++]; + return got; +} +int basis_io_write_full(basis_io_t* io, const uint8_t* b, int len) { (void)io; (void)b; return len; } +basis_io_t* basis_io_connect(const char* h, int p, int t) { (void)h; (void)p; (void)t; return NULL; } +void basis_io_close(basis_io_t* io) { (void)io; } + +#include "protocol/basis_rtmp.c" + +/* ---- counting sink -------------------------------------------------------- */ +static volatile uint8_t g_sink_byte; +static void touch(const uint8_t* p, int len) { + if (len < 0 || (len > 0 && p == NULL)) abort(); + uint8_t acc = 0; + for (int i = 0; i < len; i++) acc ^= p[i]; + g_sink_byte ^= acc; +} +static void s_vau(void* u, const uint8_t* d, int n, int64_t a, int64_t b, int k) { (void)u; (void)a; (void)b; (void)k; touch(d, n); } +static void s_afr(void* u, const uint8_t* d, int n, int64_t a) { (void)u; (void)a; touch(d, n); } +static void s_vfmt(void* u, basis_codec_t c, const uint8_t* e, int el, int w, int h) { (void)u; (void)c; (void)w; (void)h; touch(e, el); } +static void s_afmt(void* u, basis_codec_t c, int r, int ch, const uint8_t* a, int al) { (void)u; (void)c; (void)r; (void)ch; touch(a, al); } +static void s_state(void* u, basis_media_state_t s) { (void)u; (void)s; } +static void s_err(void* u, const char* m) { (void)u; (void)m; } +static void s_eos(void* u) { (void)u; } +static int s_run(void* u) { (void)u; return 1; } + +static void make_sink(basis_media_sink_t* sink) { + memset(sink, 0, sizeof(*sink)); + sink->on_video_format = s_vfmt; + sink->on_video_au = s_vau; + sink->on_audio_format = s_afmt; + sink->on_audio_frame = s_afr; + sink->on_state = s_state; + sink->on_error = s_err; + sink->on_end_of_stream = s_eos; + sink->is_running = s_run; +} + +int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size > (1u << 20)) return 0; + g_data = data; g_size = size; g_pos = 0; + + basis_media_sink_t sink; + make_sink(&sink); + + /* AMF _result walk. */ + amf_find_stream_id(data, (int)size); + + /* FLV video/audio tag parsers. Copy to a writable heap buffer (the parsers + * treat the chunk as read-only, but never hand a fuzzer's read-only mapping + * to code that might write). */ + { + uint8_t* copy = (uint8_t*)malloc(size ? size : 1); + if (copy) { + if (size) memcpy(copy, data, size); + rtmp_t r; memset(&r, 0, sizeof(r)); + r.in_chunk_size = RTMP_DEFAULT_CHUNK; r.video_nls = 4; r.video_codec = BASIS_CODEC_H264; + chunk_state_t c; memset(&c, 0, sizeof(c)); + c.buf = copy; c.have = (int)size; c.len = (uint32_t)size; + c.type = 9; handle_video(&r, &sink, &c); + c.type = 8; handle_audio(&r, &sink, &c); + free(copy); + } + } + + /* Chunk-header assembler, fed the fuzz bytes through the io stub. */ + { + rtmp_t r; memset(&r, 0, sizeof(r)); + r.in_chunk_size = RTMP_DEFAULT_CHUNK; + r.io = (basis_io_t*)&r; + g_pos = 0; + for (int i = 0; i < 4096; i++) { + if (rtmp_read_message(&r) < 0) break; + } + for (int i = 0; i < MAX_CSID; i++) free(r.cs[i].buf); + } + return 0; +} diff --git a/tools/media-fuzz/native/fuzz_rtsp.c b/tools/media-fuzz/native/fuzz_rtsp.c new file mode 100644 index 0000000000..67fe9a6fa0 --- /dev/null +++ b/tools/media-fuzz/native/fuzz_rtsp.c @@ -0,0 +1,122 @@ +/* + * fuzz_rtsp - libFuzzer target for the RTSP response/RTP parsers. + * + * RTSP opens its own socket, so it can't be driven by a read_fn the way the + * demuxers are. This target reaches the parsers two ways, both against the real + * shipping basis_rtsp.c (compiled in via #include, so a find is a find in the + * shipping parser): + * - directly on the buffer-taking parsers: parse_sdp (the DESCRIBE body), + * depkt_video / depkt_audio (RTP depacketisation + FU/AP/afrag reassembly — + * where the reassembly-cap and afrag-drop paths live); + * - through rtsp_recv (status line / headers / interleaved framing), fed by a + * link-time basis_io stub that serves the fuzz bytes in place of a socket. + * + * The other basis_io entry points are stubbed no-ops purely to satisfy the link + * (basis_rtsp_run references them but is never called here). + * + * Build: see ../build.sh (clang -fsanitize=fuzzer,address,undefined). + */ +#include +#include +#include +#include + +#include "basis_media_internal.h" +#include "protocol/basis_io.h" + +/* Fuzz bytes served to the byte-reading parsers (rtsp_recv) via the io stub. */ +static const uint8_t* g_data; +static size_t g_size; +static size_t g_pos; + +/* ---- basis_io stub: serve fuzz bytes; everything else is a no-op ---------- */ +int basis_io_read_full(basis_io_t* io, uint8_t* buf, int len) { + (void)io; + int got = 0; + while (got < len && g_pos < g_size) buf[got++] = g_data[g_pos++]; + return got; /* 0) b[0] = 0; return -1; } +int basis_io_poll_read(basis_io_t** i, int n, int t) { (void)i; (void)n; (void)t; return 0; } +void basis_io_set_read_timeout(basis_io_t* io, int t) { (void)io; (void)t; } +int basis_io_udp_connect(basis_io_t* io, const char* h, int p) { (void)io; (void)h; (void)p; return -1; } +int basis_io_udp_open_pair(const char* h, basis_io_t** a, basis_io_t** b, int* p) { + (void)h; if (a) *a = NULL; if (b) *b = NULL; if (p) *p = 0; return -1; +} + +/* Pull in the real parser (statics become visible in this TU). */ +#include "protocol/basis_rtsp.c" + +/* ---- counting sink: force ASan to read every emitted payload -------------- */ +static volatile uint8_t g_sink_byte; +static void touch(const uint8_t* p, int len) { + if (len < 0 || (len > 0 && p == NULL)) abort(); + uint8_t acc = 0; + for (int i = 0; i < len; i++) acc ^= p[i]; + g_sink_byte ^= acc; +} +static void s_vau(void* u, const uint8_t* d, int n, int64_t a, int64_t b, int k) { (void)u; (void)a; (void)b; (void)k; touch(d, n); } +static void s_afr(void* u, const uint8_t* d, int n, int64_t a) { (void)u; (void)a; touch(d, n); } +static void s_vfmt(void* u, basis_codec_t c, const uint8_t* e, int el, int w, int h) { (void)u; (void)c; (void)w; (void)h; touch(e, el); } +static void s_afmt(void* u, basis_codec_t c, int r, int ch, const uint8_t* a, int al) { (void)u; (void)c; (void)r; (void)ch; touch(a, al); } +static void s_state(void* u, basis_media_state_t s) { (void)u; (void)s; } +static void s_err(void* u, const char* m) { (void)u; (void)m; } +static void s_eos(void* u) { (void)u; } +static int s_run(void* u) { (void)u; return 1; } + +static void make_sink(basis_media_sink_t* sink) { + memset(sink, 0, sizeof(*sink)); + sink->on_video_format = s_vfmt; + sink->on_video_au = s_vau; + sink->on_audio_format = s_afmt; + sink->on_audio_frame = s_afr; + sink->on_state = s_state; + sink->on_error = s_err; + sink->on_end_of_stream = s_eos; + sink->is_running = s_run; +} + +static void run_depkt(basis_media_sink_t* sink, basis_codec_t vcodec, const uint8_t* data, int len) { + depkt_t d; + memset(&d, 0, sizeof(d)); + sdp_media_t vid, aud; + memset(&vid, 0, sizeof(vid)); vid.codec = vcodec; vid.clock = 90000; + memset(&aud, 0, sizeof(aud)); aud.codec = BASIS_CODEC_AAC; aud.clock = 48000; aud.channels = 2; + d.sink = sink; d.video = &vid; d.audio = &aud; + depkt_video(&d, data, len); + depkt_audio(&d, data, len); + free(d.au); free(d.fu); free(d.afrag); +} + +int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size > (1u << 20)) return 0; /* keep individual runs bounded */ + g_data = data; g_size = size; g_pos = 0; + + basis_media_sink_t sink; + make_sink(&sink); + + /* SDP body parser (server-controlled DESCRIBE response text). */ + { sdp_media_t v, a; parse_sdp((const char*)data, (int)size, &v, &a); } + + /* RTP depacketisation for both NAL layouts (FU/AP/afrag reassembly). */ + run_depkt(&sink, BASIS_CODEC_H264, data, (int)size); + run_depkt(&sink, BASIS_CODEC_H265, data, (int)size); + + /* RTSP response reader: status line / headers / body / interleaved framing, + * fed the fuzz bytes through the io stub. */ + { + rtsp_t r; + memset(&r, 0, sizeof(r)); + r.io = (basis_io_t*)&r; /* non-NULL; the stub ignores it */ + char body[8192]; + int blen = 0; + g_pos = 0; + rtsp_recv(&r, body, (int)sizeof(body) - 1, &blen); + } + return 0; +} diff --git a/tools/media-fuzz/native/fuzz_url.c b/tools/media-fuzz/native/fuzz_url.c new file mode 100644 index 0000000000..2755327ae5 --- /dev/null +++ b/tools/media-fuzz/native/fuzz_url.c @@ -0,0 +1,38 @@ +/* + * fuzz_url - libFuzzer target for the URL parser (basis_url_parse). + * + * The URL is the first attacker-controlled string the player touches: in + * multiplayer a peer broadcasts it and every client parses it. basis_url_parse + * splits scheme/userinfo/host/port/path by hand, so a malformed URL that walks + * off the buffer or miscomputes a copy length faults here under ASan/UBSan + * instead of on a user's machine. + * + * Build: see ../build.sh (clang -fsanitize=fuzzer,address,undefined). + */ +#include +#include +#include +#include + +#include "protocol/basis_url.h" + +int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + /* basis_url_parse wants a NUL-terminated C string; copy so a missing + * terminator in the fuzz input can't itself read past the buffer. */ + char* s = (char*)malloc(size + 1); + if (!s) return 0; + memcpy(s, data, size); + s[size] = 0; + + basis_url_t u; + /* Read every out field on success so ASan validates the copies basis_url_parse + * made (host/path/user/pass are fixed arrays it fills from the input). */ + if (basis_url_parse(s, &u) == 0) { + volatile size_t sink = 0; + sink ^= strlen(u.scheme) ^ strlen(u.host) ^ strlen(u.path) + ^ strlen(u.user) ^ strlen(u.pass) ^ (size_t)u.port; + (void)sink; + } + free(s); + return 0; +} diff --git a/tools/media-fuzz/testcases/rtmp/chunk_streamid_shift_ub.bin b/tools/media-fuzz/testcases/rtmp/chunk_streamid_shift_ub.bin new file mode 100644 index 0000000000..efb14523b2 Binary files /dev/null and b/tools/media-fuzz/testcases/rtmp/chunk_streamid_shift_ub.bin differ diff --git a/tools/media-fuzz/testcases/rtsp/rtp_ts_shift_ub.bin b/tools/media-fuzz/testcases/rtsp/rtp_ts_shift_ub.bin new file mode 100644 index 0000000000..bc3b7231b2 Binary files /dev/null and b/tools/media-fuzz/testcases/rtsp/rtp_ts_shift_ub.bin differ diff --git a/tools/media-test-streams/README.md b/tools/media-test-streams/README.md deleted file mode 100644 index 7d863523cc..0000000000 --- a/tools/media-test-streams/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# Media test-stream stack - -Self-hostable test streams for `com.basis.mediaplayer` development — the lanes that have no -reliable public endpoint. The companion guide, -[`Basis/Packages/com.basis.mediaplayer/TESTING.md`](../../Basis/Packages/com.basis.mediaplayer/TESTING.md), -explains what to test against these streams and lists the public always-on endpoints that -cover the common lanes with zero setup. - -Runs in two modes: - -- **Local (Editor iteration).** The player allows loopback URLs in the Editor, so - `docker compose up -d` on your dev machine and `rtsp://localhost:8554/main` in the Editor - is the fastest loop. Builds refuse loopback — local mode is Editor-only by design. -- **Public VPS (builds, Quest, multi-client).** The same stack on any cheap VPS with a public - IP and a DNS name. Private/LAN addresses are refused on every platform, so there is no - in-between: it's localhost-in-Editor or properly public. - -## Quick start - -```bash -# 1. Prepare assets from your own test clip (real footage with visible speech -# recommended; 6-8 audio channels unlock the multichannel lanes) -scripts/prepare-assets.sh ~/my-test-clip.mp4 - -# 2. Start the stack -docker compose up -d - -# 3. Prove the feeds are healthy before testing the player -python3 scripts/preflight.py # local -python3 scripts/preflight.py --host my.vps # deployed -``` - -## Lanes - -| URL (`` = `localhost` or your VPS) | Lane | Notes | -| --- | --- | --- | -| `rtsp://:8554/main` | RTSP live, 2 s GOP | The reference live path — joins land a frame within ~2 s. Negotiates UDP transport with TCP fallback; `rtspt://` of the same URL pins TCP for the forced-TCP lane | -| `rtsp://:8554/silent` | Video + silent stereo | Regression cover for effectively video-only sources | -| `rtsp://:8554/slowjoin` | **Adversarial** ~10 s GOP | Mid-stream joins wait up to 10 s for an IDR — deliberately; audio-first joins here are expected | -| `http://:8082/captions.ts` | CEA-608 in-band captions, HTTP-TS | Generated fixture (silent test pattern); cues every ~3 s incl. accents, music note, clear; single-client feeder | -| `rtmp://:1935/main` | RTMP pull | The player's minimal RTMP client (plain `rtmp://` only) | -| `http://:8081/live.ts` | HTTP-TS live | Single-client feeder: serves one connection, respawns on disconnect | -| `http://:8080/assets/mezzanine.mp4` | Progressive MP4 VOD | nginx answers real 206 → `Delivery=Auto` detects OnDemand | -| `http://:8080/assets/hls/index.m3u8` | HLS VOD | Single-rendition packaging of the mezzanine | -| `http://:8080/assets/lpcm.m2ts` | LPCM multichannel M2TS | Only produced from a ≥6-ch source; the full-7.1 lane | -| `http://:8080/assets/w8.wav` | Multichannel WAV | Audio-only; name varies with source channel count | -| `http://:8080/assets/videoonly.mp4` + `audioonly.mp4` | Split-stream pair | Load the video URL with the audio URL as the separate audio leg | -| `rist://:5000` | RIST plain | `--profile rist`; see below | -| `rist://:5001?secret=&aes-type=128` | RIST AES-128 | Same PSK on both ends; change the compose file's placeholder | - -`http://:8080/assets/` autoindexes, so anything else you drop into `assets/` is served -with range support too. - -## RIST lanes - -```bash -docker compose --profile rist up -d -``` - -Two extra requirements: - -- **Player side**: the native plugin must be built with `-DBASIS_WITH_RIST=ON` (see the - package README's build section) — stock builds refuse `rist://`. -- **Sender side**: the ffmpeg in the container must include librist. Verify with - `docker compose run --rm rist-plain -protocols 2>/dev/null | grep rist`; if it's missing, - point the two rist services at any ffmpeg image with librist, or run the same commands with - a host ffmpeg "full" build. - -Change the AES lane's pre-shared key in `docker-compose.yml` before deploying anywhere shared. - -## Deploying on a VPS - -1. Any small VPS works — the publishers `-c copy` pre-encoded assets, so CPU stays near idle. -2. Copy this directory up, run `prepare-assets.sh` there (or copy a prepared `assets/`), then - `docker compose up -d`. -3. Open the inbound ports at **every** firewall layer (many providers gate ports in a panel - *in addition to* the OS firewall): `8554/tcp`, `1935/tcp`, `8080/tcp`, `8081-8082/tcp`, - and for RIST `5000-5001/udp`. -4. Give it a DNS name. Hostnames are DNS-validated by the player, and Quest's cleartext - policy means the HTTP lanes (`8080`–`8082`) need to sit behind TLS with a certificate - chain standalone headsets actually trust (serve the full chain including intermediates — - headset trust stores are sparser than desktop browsers'). RTSP needs no TLS. -5. `python3 scripts/preflight.py --host ` before every session. - -## Gotchas - -- **One client per HTTP-TS slot.** The `tslive` and `cclive` feeders serve a single - connection then exit and respawn. If one wedges without exiting (stale ~0.5 Mbps trickle — - the preflight's bitrate floor catches this), `docker compose restart tslive` (or `cclive`). -- **Don't benchmark with `curl.exe` on Windows** — it under-reads regardless of server. Use - `preflight.py`'s sampled throughput. -- **Slow joins on `slowjoin` are the point.** File a bug only if the join exceeds the GOP - length or A/V never locks after the first IDR. -- **Link capacity is a real variable.** A stream whose bitrate exceeds your path to the VPS - cannot play live regardless of player behaviour; check the preflight throughput numbers - against the asset bitrate before chasing "stutter". -- **Asset licensing.** `prepare-assets.sh` derives everything from the clip you supply; use - content you have the rights to put on a public endpoint. diff --git a/tools/media-test-streams/docker-compose.yml b/tools/media-test-streams/docker-compose.yml deleted file mode 100644 index 169a09a14f..0000000000 --- a/tools/media-test-streams/docker-compose.yml +++ /dev/null @@ -1,135 +0,0 @@ -# Test-stream stack for the Basis media player. See README.md for usage. -# -# Runs locally (Editor allows loopback URLs) or on any public VPS (builds/Quest -# require a public host). Prepare ./assets first: scripts/prepare-assets.sh -# -# Profiles: -# default rtsp/rtspt lanes + VOD + HTTP-TS live -# rist RIST senders (needs an ffmpeg image built with librist) - -services: - # RTSP server, UDP + TCP transports (rtsp://:8554/ negotiates, - # rtspt:// pins TCP), plus the RTMP pull lane on 1935 - # (rtmp://:1935/). - # Publishers also push in over RTMP: it carries whole access units, while - # ffmpeg's RTSP output fragments large AAC frames in ways ingest - # depacketisers reject. - mediamtx: - image: bluenviron/mediamtx:latest - restart: unless-stopped - ports: - - "8554:8554" # RTSP control (TCP) - - "8000:8000/udp" # RTP (UDP transport) - - "8001:8001/udp" # RTCP (UDP transport; carries client receiver reports) - - "1935:1935" # RTMP (ingest + player pull lane) - volumes: - - ./mediamtx.yml:/mediamtx.yml:ro - - # Looping publishers. -c copy from pre-encoded assets: zero transcode CPU. - pub-main: &publisher - image: linuxserver/ffmpeg:latest - restart: unless-stopped - depends_on: [mediamtx] - volumes: - - ./assets:/assets:ro - command: >- - -hide_banner -loglevel warning - -re -stream_loop -1 -i /assets/mezzanine.mp4 - -c copy -f flv rtmp://mediamtx:1935/main - - # Silent-audio variant (video + silent stereo): regression cover for sources - # whose SDP would otherwise be video-only. - pub-silent: - <<: *publisher - command: >- - -hide_banner -loglevel warning - -re -stream_loop -1 -i /assets/silent.mp4 - -c copy -f flv rtmp://mediamtx:1935/silent - - # Adversarial long-GOP path: a mid-stream join waits up to the full GOP for - # an IDR — deliberate repro for slow-join symptoms. Slow joins here are - # expected, not bugs. - pub-slowjoin: - <<: *publisher - command: >- - -hide_banner -loglevel warning - -re -stream_loop -1 -i /assets/slowjoin.mp4 - -c copy -f flv rtmp://mediamtx:1935/slowjoin - - # CEA-608 caption fixture over HTTP-TS: http://:8082/captions.ts - # (silent test pattern with in-band captions in the H.264 SEIs; -c copy - # preserves them). Served raw rather than via RTMP: the fixture loops, and - # the player treats the per-loop timestamp wrap as a new timeline, while the - # FLV mux does not tolerate it. Single-client feeder like tslive. - cclive: - image: linuxserver/ffmpeg:latest - restart: unless-stopped - ports: - - "8082:8082" - volumes: - - ./assets:/assets:ro - command: >- - -hide_banner -loglevel warning - -re -stream_loop -1 -i /assets/test_cc.ts - -c copy -f mpegts -listen 1 http://0.0.0.0:8082/captions.ts - - # VOD over HTTP with real range support (nginx answers 206, so the player's - # Delivery=Auto probe detects OnDemand). Serves everything in ./assets. - vod: - image: nginx:alpine - restart: unless-stopped - ports: - - "8080:80" - volumes: - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - - ./assets:/srv/assets:ro - - # HTTP-TS live lane: http://:8081/live.ts - # ffmpeg -listen serves ONE client, then exits; restart: unless-stopped - # respawns a fresh slot. A wedged (connected-but-stale) slot needs a manual - # `docker compose restart tslive`. - tslive: - image: linuxserver/ffmpeg:latest - restart: unless-stopped - ports: - - "8081:8081" - volumes: - - ./assets:/assets:ro - command: >- - -hide_banner -loglevel warning - -re -stream_loop -1 -i /assets/mezzanine.mp4 - -c copy -f mpegts -listen 1 http://0.0.0.0:8081/live.ts - - # --- RIST lanes (profile: rist) ------------------------------------------ - # The player side needs the plugin built with -DBASIS_WITH_RIST=ON. - # The sender side needs ffmpeg with librist compiled in — check with: - # docker compose run --rm rist-plain -protocols 2>/dev/null | grep rist - # Sender runs in listener mode; the player connects to rist://:5000. - rist-plain: - image: linuxserver/ffmpeg:latest - restart: unless-stopped - profiles: [rist] - ports: - - "5000:5000/udp" - volumes: - - ./assets:/assets:ro - command: >- - -hide_banner -loglevel warning - -re -stream_loop -1 -i /assets/mezzanine.mp4 - -c copy -f mpegts -rist_profile main "rist://@:5000" - - # AES-128 lane: rist://:5001?secret=&aes-type=128 - # Replace the PSK below (and use the same value in the player URL). - rist-aes: - image: linuxserver/ffmpeg:latest - restart: unless-stopped - profiles: [rist] - ports: - - "5001:5001/udp" - volumes: - - ./assets:/assets:ro - command: >- - -hide_banner -loglevel warning - -re -stream_loop -1 -i /assets/mezzanine.mp4 - -c copy -f mpegts -rist_profile main - "rist://@:5001?secret=change-this-preshared-key&aes-type=128" diff --git a/tools/media-test-streams/mediamtx.yml b/tools/media-test-streams/mediamtx.yml deleted file mode 100644 index e6f2d0ac92..0000000000 --- a/tools/media-test-streams/mediamtx.yml +++ /dev/null @@ -1,23 +0,0 @@ -# mediamtx config for the Basis media-player test stack. -# RTSP with both transports: rtsp:// negotiates UDP first and falls back to -# TCP; rtspt:// pins TCP. Set rtspTransports: [tcp] to test the player's -# refusal fallback (UDP SETUP answered 461). RTMP doubles as the publisher -# ingest and the player's rtmp:// pull lane. - -logLevel: info - -rtsp: yes -rtspTransports: [udp, tcp] -rtspAddress: :8554 -rtpAddress: :8000 -rtcpAddress: :8001 - -rtmp: yes -rtmpAddress: :1935 - -hls: no -webrtc: no -srt: no - -paths: - all_others: diff --git a/tools/media-test-streams/nginx.conf b/tools/media-test-streams/nginx.conf deleted file mode 100644 index 20f9f5603c..0000000000 --- a/tools/media-test-streams/nginx.conf +++ /dev/null @@ -1,13 +0,0 @@ -# VOD lane: static files with real range support (nginx answers 206 Partial -# Content, which the player's Delivery=Auto probe requires to detect VOD). -server { - listen 80; - root /srv; - - location /assets/ { - autoindex on; - # Large media files: stream from disk, don't buffer whole files in RAM. - sendfile on; - tcp_nopush on; - } -} diff --git a/tools/media-test-streams/scripts/gen_cc_ts.py b/tools/media-test-streams/scripts/gen_cc_ts.py deleted file mode 100644 index 579e4110d1..0000000000 --- a/tools/media-test-streams/scripts/gen_cc_ts.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate a looping MPEG-TS test clip carrying in-band CEA-608 (CC1) captions, to -exercise the media player's caption decoder end to end. - -ffmpeg can read/repack captions but not synthesise them, so this builds the -caption bytes itself: it encodes pop-on CEA-608 command sequences, wraps them in -ATSC A/53 (GA94) cc_data SEI NAL units, and splices one in at each caption-change -frame of an H.264 Annex B elementary stream produced by ffmpeg. A second ffmpeg -pass muxes the result to MPEG-TS. - -Usage: gen_cc_ts.py [output.ts] (default: test_cc.ts beside this script) -Verify: ffprobe -show_frames output.ts | grep -i caption ("ATSC A53" side data) -""" -import os -import subprocess -import sys -import tempfile - -FPS = 30 -DUR = 20 # seconds -W, H = 854, 480 - -# Schedule: (frame_index, caption_text or None-for-clear). Pop-on; the cue persists -# until the next entry, so a change every ~3s reads comfortably. -SCHEDULE = [ - (0, "CEA-608 CAPTION TEST"), - (90, "THE QUICK BROWN FOX"), - (180, "JUMPS OVER THE LAZY DOG"), - (270, None), # clear - (330, "ACCENTS: cafe résumé piñata"), - (420, "MUSIC: ♪ la la la ♪"), - (510, "LAST LINE - LOOP RESTARTS"), -] - -# ---- CEA-608 encoding ---------------------------------------------------- - -def odd_parity(b): - b &= 0x7F - ones = bin(b).count("1") - return b | (0x00 if ones % 2 else 0x80) - -# Basic North American set: accented characters with dedicated codepoints. -BASIC = {"á": 0x2A, "é": 0x5C, "í": 0x5E, "ó": 0x5F, "ú": 0x60, - "ç": 0x7B, "÷": 0x7C, "Ñ": 0x7D, "ñ": 0x7E} -# Special chars live in a control pair (0x11, 0x30-0x3F). -SPECIAL = {"♪": 0x37} # music note - -def char_pairs(text): - """Yield (b0,b1) byte pairs for the text body (no parity yet).""" - pending = [] - out = [] - for ch in text: - if ch in SPECIAL: # special char is its own control pair - if len(pending) == 1: - out.append((pending[0], 0x00)); pending = [] - out.append((0x11, SPECIAL[ch])) - continue - code = BASIC.get(ch) - if code is None: - o = ord(ch) - code = o if 0x20 <= o <= 0x7F else 0x20 # fall back to space - pending.append(code) - if len(pending) == 2: - out.append((pending[0], pending[1])); pending = [] - if pending: - out.append((pending[0], 0x00)) - return out - -RCL = (0x14, 0x20); ENM = (0x14, 0x2E); EDM = (0x14, 0x2C); EOC = (0x14, 0x2F) -PAC_R15 = (0x14, 0x60) # row 15, white, column 0 - -def encode_caption(text): - if text is None: - return [EDM] - return [RCL, ENM, PAC_R15] + char_pairs(text) + [EOC] - -# ---- SEI / NAL assembly -------------------------------------------------- - -def cc_data(pairs): - n = len(pairs) - assert n <= 31 - b = bytearray() - b.append(0xC0 | n) # reserved=1, process_cc_data_flag=1, additional=0, cc_count - b.append(0xFF) # em_data - for d1, d2 in pairs: - b.append(0xFC) # marker(11111) | cc_valid(1) | cc_type(00 = 608 field 1) - b.append(odd_parity(d1)) - b.append(odd_parity(d2)) - b.append(0xFF) # trailing marker - return bytes(b) - -def user_data(pairs): - p = bytearray() - p += bytes([0xB5, 0x00, 0x31]) # country USA, provider ATSC - p += b"GA94" # user_identifier - p.append(0x03) # user_data_type_code = cc_data - p += cc_data(pairs) - return bytes(p) - -def emulation_escape(rbsp): - out = bytearray() - zeros = 0 - for byte in rbsp: - if zeros >= 2 and byte <= 0x03: - out.append(0x03) - zeros = 0 - out.append(byte) - zeros = zeros + 1 if byte == 0 else 0 - return bytes(out) - -def sei_nal(pairs): - payload = user_data(pairs) - msg = bytearray() - msg.append(0x04) # payloadType = user_data_registered_itu_t_t35 - size = len(payload) - while size >= 255: - msg.append(0xFF); size -= 255 - msg.append(size) - msg += payload - rbsp = bytes(msg) + b"\x80" # rbsp_trailing_bits - return b"\x00\x00\x00\x01" + b"\x06" + emulation_escape(rbsp) - -# ---- Annex B splicing ---------------------------------------------------- - -def find_nals(data): - """Yield (start_index_of_startcode, nal_type) for each NAL.""" - i, n = 0, len(data) - while i + 3 < n: - if data[i] == 0 and data[i+1] == 0 and data[i+2] == 1: - sc = 3 - elif data[i] == 0 and data[i+1] == 0 and data[i+2] == 0 and data[i+3] == 1: - sc = 4 - else: - i += 1; continue - nal_type = data[i+sc] & 0x1F - yield (i, nal_type) - i += sc + 1 - -def splice(data, schedule): - """Insert an SEI NAL into each scheduled access unit, in AU order: after - AUD/SPS/PPS and immediately before the first VCL slice NAL.""" - by_frame = dict(schedule) - nals = list(find_nals(data)) - out = bytearray() - au_index = -1 - pending = None # caption pairs awaiting the slice NAL - for k, (off, ntype) in enumerate(nals): - end = nals[k+1][0] if k + 1 < len(nals) else len(data) - if ntype == 9: # AUD => start of a new access unit - au_index += 1 - pending = encode_caption(by_frame[au_index]) if au_index in by_frame else None - if 1 <= ntype <= 5 and pending is not None: - out += sei_nal(pending) # prefix SEI sits before the coded picture - pending = None - out += data[off:end] - return bytes(out) - -# ---- pipeline ------------------------------------------------------------ - -def run(cmd): - print("+", " ".join(cmd)) - subprocess.run(cmd, check=True) - -def main(): - out_path = sys.argv[1] if len(sys.argv) > 1 else \ - os.path.join(os.path.dirname(os.path.abspath(__file__)), "test_cc.ts") - with tempfile.TemporaryDirectory() as tmp: - base = os.path.join(tmp, "base.h264") - mod = os.path.join(tmp, "mod.h264") - # No B-frames (bframes=0): the raw-H.264 demuxer in the copy pass can't - # derive DTS for reordered frames, which makes the MPEG-TS muxer reject - # the stream. - run(["ffmpeg", "-y", "-f", "lavfi", "-i", f"testsrc2=s={W}x{H}:r={FPS}:d={DUR}", - "-pix_fmt", "yuv420p", "-c:v", "libx264", "-profile:v", "baseline", - "-g", str(FPS), "-keyint_min", str(FPS), "-bf", "0", - "-x264-params", "aud=1:scenecut=0", "-f", "h264", base]) - data = open(base, "rb").read() - spliced = splice(data, SCHEDULE) - open(mod, "wb").write(spliced) - print(f" spliced {len(SCHEDULE)} caption SEIs ({len(data)} -> {len(spliced)} bytes)") - run(["ffmpeg", "-y", "-framerate", str(FPS), "-i", mod, "-c", "copy", - "-muxrate", "3M", "-f", "mpegts", out_path]) - print(f"\nWrote {out_path}") - -if __name__ == "__main__": - main() diff --git a/tools/media-test-streams/scripts/preflight.py b/tools/media-test-streams/scripts/preflight.py deleted file mode 100644 index 737fa9f0c9..0000000000 --- a/tools/media-test-streams/scripts/preflight.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -"""Preflight probe for the test-stream stack. - -Run BEFORE any media-player test session (~30s): - - python3 preflight.py # probe everything on localhost - python3 preflight.py --host my.vps # probe a deployed stack - python3 preflight.py --host my.vps main captions # matching lanes only - -Catches sick feeders in seconds instead of after a confusing editor session: -a symptom seen in the player only earns player-side investigation time if the -preflight is green. - -Requires ffmpeg + ffprobe on PATH. Do NOT use curl.exe on Windows for -throughput checks (it under-reads badly); this script uses urllib. -""" -import argparse -import json -import subprocess -import time -import urllib.request - -# (name, kind, path_template, threshold) -# rtsp: threshold = max seconds to first decoded frame (None = informational; -# slowjoin waits for an IDR in a ~10s GOP by design) -# http: threshold = min Mbps over a short sample (catches stale ~0.5Mbps -# feeder slots, not nominal bitrate — -re paced feeds dip on quiet scenes) -# vod: threshold unused; checks for a real 206 Partial Content -LANES = [ - ("main", "rtsp", "rtsp://{host}:8554/main", 6.0), - ("silent", "rtsp", "rtsp://{host}:8554/silent", 6.0), - ("slowjoin", "rtsp", "rtsp://{host}:8554/slowjoin", None), - ("rtmp", "rtsp", "rtmp://{host}:1935/main", 6.0), - ("vod", "vod", "http://{host}:8080/assets/mezzanine.mp4", None), - ("hls-vod", "vod", "http://{host}:8080/assets/hls/index.m3u8", None), - ("tslive", "http", "http://{host}:8081/live.ts", 1.0), - ("captions", "http", "http://{host}:8082/captions.ts", 1.0), -] - - -def probe_rtsp(name, url, limit): - """Time-to-first-decoded-video-frame + stream shape (RTSP/TCP or RTMP).""" - transport = ["-rtsp_transport", "tcp"] if url.startswith("rtsp") else [] - t0 = time.monotonic() - try: - out = subprocess.run( - ["ffmpeg", "-v", "error"] + transport + ["-i", url, - "-map", "0:v:0", "-frames:v", "1", "-f", "null", "-"], - capture_output=True, text=True, timeout=25) - except subprocess.TimeoutExpired: - return f"FAIL {name}: no decodable video frame within 25s" - ttff = time.monotonic() - t0 - if out.returncode != 0: - err = [l for l in out.stderr.strip().splitlines() - if "Missing reference" not in l and "mmco" not in l] - return f"FAIL {name}: {err[-1] if err else 'ffmpeg error'}" - streams = subprocess.run( - ["ffprobe", "-v", "error"] + transport + ["-show_entries", - "stream=codec_type,codec_name,channels", "-of", "json", url], - capture_output=True, text=True, timeout=25) - shape = ",".join( - f"{s['codec_name']}({s.get('channels')}ch)" if s["codec_type"] == "audio" - else s["codec_name"] - for s in json.loads(streams.stdout or '{"streams":[]}')["streams"]) - verdict = "PASS" if (limit is None or ttff < limit) else "SLOW" - note = "" if limit else " (long-GOP adversarial path: slow join is expected)" - return f"{verdict} {name}: first frame {ttff:.1f}s, streams [{shape}]{note}" - - -def probe_http(name, url, min_mbps, seconds=5): - """First-byte latency + short bitrate sample. Consumes a single-client - feeder slot; the container respawns a fresh one on disconnect.""" - t0 = time.monotonic() - try: - resp = urllib.request.urlopen(url, timeout=10) - first = resp.read(64 * 1024) - fb = time.monotonic() - t0 - total = len(first) - t1 = time.monotonic() - while time.monotonic() - t1 < seconds: - chunk = resp.read(256 * 1024) - if not chunk: - break - total += len(chunk) - resp.close() - except Exception as ex: - return f"FAIL {name}: {ex}" - mbps = total * 8 / max(time.monotonic() - t0, 0.001) / 1e6 - verdict = "PASS" if mbps >= min_mbps else "FAIL" - return f"{verdict} {name}: first byte {fb:.2f}s, {mbps:.1f} Mbps sampled" - - -def probe_vod(name, url): - """The player's Delivery=Auto probe needs a real 206 for OnDemand.""" - req = urllib.request.Request(url, headers={"Range": "bytes=0-1023"}) - try: - resp = urllib.request.urlopen(req, timeout=10) - code = resp.getcode() - resp.read(1024) - resp.close() - except Exception as ex: - return f"FAIL {name}: {ex}" - if code == 206: - return f"PASS {name}: 206 Partial Content (detected as OnDemand)" - return f"FAIL {name}: got {code}, not 206 — will be treated as LIVE" - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--host", default="localhost") - ap.add_argument("filters", nargs="*", help="only lanes whose name contains a filter") - args = ap.parse_args() - - results = [] - for name, kind, tmpl, threshold in LANES: - if args.filters and not any(f in name for f in args.filters): - continue - url = tmpl.format(host=args.host) - if kind == "rtsp": - results.append(probe_rtsp(name, url, threshold)) - elif kind == "http": - results.append(probe_http(name, url, threshold)) - else: - results.append(probe_vod(name, url)) - print(results[-1], flush=True) - - fails = sum(1 for r in results if r.startswith("FAIL")) - print(f"\n{len(results)} lanes probed, {fails} failing") - raise SystemExit(1 if fails else 0) - - -if __name__ == "__main__": - main() diff --git a/tools/media-test-streams/scripts/prepare-assets.sh b/tools/media-test-streams/scripts/prepare-assets.sh deleted file mode 100644 index c76c89508d..0000000000 --- a/tools/media-test-streams/scripts/prepare-assets.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# Prepare the ./assets content the test-stream stack serves. -# -# Usage: scripts/prepare-assets.sh -# -# is your own test clip. Prefer real footage with visible speech -# (lip-sync moments make A/V drift visible; synthetic patterns hide it) and, -# for the multichannel lanes, a source with 6 or 8 audio channels. Everything -# is derived from this one file. -# -# Needs ffmpeg/ffprobe on PATH. The LPCM lane needs a build with the -# pcm_bluray encoder and the caption fixture needs python3 (both optional — -# skipped with a warning if unavailable). -set -euo pipefail - -IN="${1:?usage: prepare-assets.sh }" -HERE="$(cd "$(dirname "$0")/.." && pwd)" -OUT="$HERE/assets" -mkdir -p "$OUT" - -channels=$(ffprobe -v error -select_streams a:0 -show_entries stream=channels \ - -of default=nw=1:nk=1 "$IN" 2>/dev/null || echo 0) -echo "== input: $IN (audio channels: $channels)" - -# Canonical live mezzanine: fixed 2 s keyframe grid so live joins land a -# decodable frame within ~2 s. Publishers -c copy from this; encode once here. -echo "== mezzanine.mp4 (2s GOP)" -ffmpeg -y -hide_banner -loglevel warning -i "$IN" \ - -c:v libx264 -crf 18 -g 48 -keyint_min 48 -sc_threshold 0 -r 24 \ - -c:a aac -b:a 384k \ - "$OUT/mezzanine.mp4" - -echo "== silent.mp4 (video + silent stereo)" -ffmpeg -y -hide_banner -loglevel warning -i "$OUT/mezzanine.mp4" \ - -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 \ - -map 0:v -map 1:a -c:v copy -c:a aac -shortest \ - "$OUT/silent.mp4" - -# Adversarial long-GOP variant: joins mid-GOP wait up to ~10 s for an IDR. -echo "== slowjoin.mp4 (10s GOP)" -ffmpeg -y -hide_banner -loglevel warning -i "$IN" \ - -c:v libx264 -crf 18 -g 240 -keyint_min 240 -sc_threshold 0 -r 24 \ - -c:a aac -b:a 384k \ - "$OUT/slowjoin.mp4" - -echo "== hls/ (HLS VOD packaging)" -mkdir -p "$OUT/hls" -ffmpeg -y -hide_banner -loglevel warning -i "$OUT/mezzanine.mp4" \ - -c copy -f hls -hls_time 4 -hls_playlist_type vod \ - -hls_segment_filename "$OUT/hls/seg%04d.ts" \ - "$OUT/hls/index.m3u8" - -# Split-stream pair (video-only + audio-only) for the AudioUri lane. -echo "== videoonly.mp4 / audioonly.mp4" -ffmpeg -y -hide_banner -loglevel warning -i "$OUT/mezzanine.mp4" \ - -map 0:v -c copy -an "$OUT/videoonly.mp4" -ffmpeg -y -hide_banner -loglevel warning -i "$OUT/mezzanine.mp4" \ - -map 0:a -c copy -vn "$OUT/audioonly.mp4" - -if [ "$channels" -ge 6 ]; then - # LPCM over M2TS: the full-multichannel lane (AAC on Windows caps at 5.1; - # LPCM plays all 8 lanes discretely). - if ffmpeg -hide_banner -encoders 2>/dev/null | grep -q pcm_bluray; then - echo "== lpcm.m2ts (LPCM ${channels}ch over M2TS)" - ffmpeg -y -hide_banner -loglevel warning -i "$IN" \ - -c:v libx264 -crf 18 -g 48 -keyint_min 48 -sc_threshold 0 -r 24 \ - -c:a pcm_bluray -ar 48000 \ - -f mpegts "$OUT/lpcm.m2ts" - else - echo "!! skipping lpcm.m2ts: this ffmpeg lacks the pcm_bluray encoder (use a full build)" - fi - - echo "== w${channels}.wav (multichannel WAV, 24-bit)" - ffmpeg -y -hide_banner -loglevel warning -i "$IN" \ - -map 0:a -c:a pcm_s24le -ar 48000 -vn "$OUT/w${channels}.wav" -else - echo "!! input has <6 audio channels: skipping LPCM/multichannel-WAV lanes" - echo "== wav (stereo, 24-bit)" - ffmpeg -y -hide_banner -loglevel warning -i "$IN" \ - -map 0:a -c:a pcm_s24le -ar 48000 -vn "$OUT/stereo.wav" -fi - -if command -v python3 >/dev/null 2>&1; then - echo "== test_cc.ts (CEA-608 caption fixture)" - python3 "$HERE/scripts/gen_cc_ts.py" "$OUT/test_cc.ts" -else - echo "!! skipping test_cc.ts: python3 not found" -fi - -echo "== done. assets in $OUT:" -ls -lh "$OUT"