diff --git a/e2e/test_http_proxy.py b/e2e/test_http_proxy.py index 532887c..f755de3 100644 --- a/e2e/test_http_proxy.py +++ b/e2e/test_http_proxy.py @@ -171,6 +171,31 @@ def test_query_forwarded(self, shared_r2h): finally: upstream.stop() + @pytest.mark.parametrize("token_param", ["r2h-token", "R2H-Token"]) + def test_r2h_token_not_forwarded(self, shared_r2h, token_param): + """r2h-token is local auth metadata and should be stripped before upstream HTTP.""" + upstream = MockHTTPUpstream( + routes={ + "/search": {"status": 200, "body": b"found"}, + } + ) + upstream.start() + try: + status, _, body = http_get( + "127.0.0.1", + shared_r2h.port, + "/http/127.0.0.1:%d/search?%s=secret-token&q=test" % (upstream.port, token_param), + timeout=5.0, + ) + assert status == 200 + assert body == b"found" + assert upstream.requests_log, "expected upstream HTTP request" + upstream_path = upstream.requests_log[0]["path"] + assert "r2h-token" not in upstream_path.lower() + assert "q=test" in upstream_path + finally: + upstream.stop() + # --------------------------------------------------------------------------- # Upstream unreachable diff --git a/e2e/test_rtsp_seek_mode.py b/e2e/test_rtsp_seek_mode.py index 7a46d61..1c85f8e 100644 --- a/e2e/test_rtsp_seek_mode.py +++ b/e2e/test_rtsp_seek_mode.py @@ -658,6 +658,39 @@ def test_configured_seek_name_does_not_leak(self, r2h_binary): finally: rtsp.stop() + def test_configured_seek_name_does_not_remap_default_playseek_request(self, r2h_binary): + """Configured r2h-seek-name selects what to consume; it does not rename playseek.""" + r2h_port = find_free_port() + rtsp = MockRTSPServer(num_packets=500) + rtsp.start() + try: + config = make_m3u_rtsp_config(r2h_port, rtsp.port, "SeekNameNoRemap", "?r2h-seek-name=tvdr") + r2h = R2HProcess(r2h_binary, r2h_port, config_content=config) + r2h.start() + try: + base_ts = 1717000000 + base_str = _format_yyyyMMddHHmmss(base_ts) + stream_get( + "127.0.0.1", + r2h_port, + "/SeekNameNoRemap?playseek=%s" % base_str, + read_bytes=4096, + timeout=_STREAM_TIMEOUT, + ) + + describe_reqs = [r for r in rtsp.requests_detailed if r["method"] == "DESCRIBE"] + assert describe_reqs, "expected at least one DESCRIBE" + uri = describe_reqs[0]["uri"] + assert "r2h-seek-name" not in uri, "r2h-seek-name leaked into upstream URI: %s" % uri + assert "playseek=%s" % base_str in uri, ( + "playseek should be forwarded as-is when configured seek name is tvdr; got: %s" % uri + ) + assert "tvdr=" not in uri, "playseek must not be remapped to tvdr: %s" % uri + finally: + r2h.stop() + finally: + rtsp.stop() + def test_request_seek_name_overrides_configured(self, r2h_binary): """Request r2h-seek-name overrides M3U-configured r2h-seek-name, and neither r2h-seek-name copy leaks upstream.""" @@ -697,6 +730,37 @@ def test_request_seek_name_overrides_configured(self, r2h_binary): finally: rtsp.stop() + @pytest.mark.parametrize("token_param", ["r2h-token", "R2H-Token"]) + def test_r2h_token_does_not_leak_to_rtsp_upstream(self, r2h_binary, token_param): + """URL-supplied r2h-token is local auth metadata and must not reach RTSP.""" + r2h_port = find_free_port() + rtsp = MockRTSPServer(num_packets=500) + rtsp.start() + try: + config = make_m3u_rtsp_config(r2h_port, rtsp.port, "TokenStrip") + r2h = R2HProcess(r2h_binary, r2h_port, config_content=config) + r2h.start() + try: + stream_get( + "127.0.0.1", + r2h_port, + "/TokenStrip?%s=secret-token" % token_param, + read_bytes=4096, + timeout=_STREAM_TIMEOUT, + ) + + assert rtsp.requests_detailed, "expected RTSP requests" + for request in rtsp.requests_detailed: + assert "r2h-token" not in request["uri"].lower(), "%s leaked into upstream %s URI: %s" % ( + token_param, + request["method"], + request["uri"], + ) + finally: + r2h.stop() + finally: + rtsp.stop() + def test_configured_ifname_fcc_does_not_leak(self, r2h_binary): """The merge function strips `r2h-ifname-fcc` from the upstream URI (per-field append block, not shared with the other 4 r2h-* params). diff --git a/e2e/test_url_template.py b/e2e/test_url_template.py index c719db3..9cb00d9 100644 --- a/e2e/test_url_template.py +++ b/e2e/test_url_template.py @@ -1547,6 +1547,68 @@ def test_query_only_templates_become_playseek_carrier(self, r2h_binary): finally: r2h.stop() + def test_query_only_tvdr_range_preserves_seek_param_name(self, r2h_binary): + """A tvdr range template should keep tvdr as the playlist seek carrier.""" + port = find_free_port() + config = f"""\ +[global] +verbosity = 4 +maxclients = 10 + +[bind] +* {port} + +[services] +#EXTM3U +#EXTINF:-1 catchup="default" catchup-source="rtsp://10.0.0.50:554/playback?tvdr=${{(b)yyyyMMddHHmmss}}GMT-${{(e)yyyyMMddHHmmss}}GMT&r2h-seek-offset=-28800",TVDR Template Ch +rtp://239.0.0.1:1234 +""" + r2h = R2HProcess(r2h_binary, port, config_content=config) + try: + r2h.start() + status, _, body = http_get("127.0.0.1", port, "/playlist.m3u") + text = body.decode() + + assert status == 200 + _, catchup_source = extract_catchup_source(text, "TVDR Template Ch") + assert "tvdr=${(b)yyyyMMddHHmmss}GMT-${(e)yyyyMMddHHmmss}GMT" in catchup_source, ( + "Expected tvdr range to stay under tvdr, got: %s" % catchup_source + ) + assert "playseek=" not in catchup_source + finally: + r2h.stop() + + def test_query_only_explicit_seek_name_preserves_param_name(self, r2h_binary): + """A configured custom seek name should be preserved as the playlist seek carrier.""" + port = find_free_port() + config = f"""\ +[global] +verbosity = 4 +maxclients = 10 + +[bind] +* {port} + +[services] +#EXTM3U +#EXTINF:-1 catchup="default" catchup-source="rtsp://10.0.0.50:554/playback?customseek={{utc:YmdHMS}}-{{utcend:YmdHMS}}&r2h-seek-name=customseek",Custom Seek Template Ch +rtp://239.0.0.1:1234 +""" + r2h = R2HProcess(r2h_binary, port, config_content=config) + try: + r2h.start() + status, _, body = http_get("127.0.0.1", port, "/playlist.m3u") + text = body.decode() + + assert status == 200 + _, catchup_source = extract_catchup_source(text, "Custom Seek Template Ch") + assert "customseek={utc:YmdHMS}-{utcend:YmdHMS}" in catchup_source, ( + "Expected explicit seek name to stay under customseek, got: %s" % catchup_source + ) + assert "playseek=" not in catchup_source + finally: + r2h.stop() + def test_no_template_no_injection(self, r2h_binary): """Catchup-source without any templates should not get playseek injected.""" port = find_free_port() diff --git a/src/m3u.c b/src/m3u.c index 347d101..ed64eb7 100644 --- a/src/m3u.c +++ b/src/m3u.c @@ -518,6 +518,7 @@ static char *extract_catchup_template_query(const char *url) { url_template_analysis_t analysis; const char *begin_value; const char *end_value; + const char *seek_param_name; char query[MAX_URL_LENGTH]; if (!url) @@ -532,16 +533,17 @@ static char *extract_catchup_template_query(const char *url) { begin_value = analysis.begin_template[0] ? analysis.begin_template : (analysis.needs_begin ? "${utc}" : ""); end_value = analysis.end_template[0] ? analysis.end_template : (analysis.needs_end ? "${utcend}" : ""); + seek_param_name = analysis.seek_param_name[0] ? analysis.seek_param_name : "playseek"; if (analysis.needs_end) { - if (snprintf(query, sizeof(query), "playseek=%s-%s", begin_value, end_value) >= (int)sizeof(query)) { + if (snprintf(query, sizeof(query), "%s=%s-%s", seek_param_name, begin_value, end_value) >= (int)sizeof(query)) { logger(LOG_WARN, "Catchup template query exceeds buffer size"); return NULL; } return strdup(query); } - if (snprintf(query, sizeof(query), "playseek=%s", begin_value) >= (int)sizeof(query)) { + if (snprintf(query, sizeof(query), "%s=%s", seek_param_name, begin_value) >= (int)sizeof(query)) { logger(LOG_WARN, "Catchup template query exceeds buffer size"); return NULL; } diff --git a/src/service.c b/src/service.c index e42e8b7..775365a 100644 --- a/src/service.c +++ b/src/service.c @@ -255,10 +255,11 @@ static void remove_query_param(char **query_start, char *param_start, char *valu /* Find first occurrence of `param_name=` in the query string anchored at qs * (the '?' character). Returns pointer to the start of the param name, or NULL. - * Read-only in practice; signature matches strstr's char*-in-char*-out idiom. */ + * Matching is case-insensitive because r2h control parameters are treated that + * way throughout the request path. */ static char *find_query_param(char *qs, const char *param_name, size_t name_len) { char *p = qs; - while ((p = strstr(p, param_name)) != NULL) { + while ((p = strcasestr(p, param_name)) != NULL) { int leading_ok = (p == qs + 1) || (p > qs && *(p - 1) == '&'); int trailing_ok = (p[name_len] == '='); if (leading_ok && trailing_ok) { @@ -708,6 +709,17 @@ static void service_extract_ifname_params(char *query_start, char **out_ifname, } } +static void service_strip_query_param(char *query_start, const char *param_name) { + char ignored_value[256]; + + if (!query_start || *query_start != '?' || !param_name) + return; + + if (extract_query_param(&query_start, param_name, ignored_value, sizeof(ignored_value)) == 1) { + logger(LOG_DEBUG, "Stripped %s from upstream URL", param_name); + } +} + static int is_valid_seek_time_value(const char *value) { time_t parsed_time; @@ -1163,6 +1175,7 @@ service_t *service_create_from_http_url(const char *http_url) { &result->seek_offset_seconds, &result->seek_mode, &result->seek_mode_tz_explicit, &result->seek_mode_tz_offset_seconds, &result->seek_mode_window_seconds); service_extract_ifname_params(query_start, &result->ifname, &result->ifname_fcc); + service_strip_query_param(query_start, "r2h-token"); } logger(LOG_DEBUG, "Created HTTP proxy service: %s -> %s", http_url, result->http_url); @@ -1251,6 +1264,7 @@ service_t *service_create_from_rtsp_url(const char *http_url) { return NULL; } service_extract_ifname_params(query_start, &ifname, &ifname_fcc); + service_strip_query_param(query_start, "r2h-token"); } /* Allocate service structure */ diff --git a/src/url_template.c b/src/url_template.c index 8873801..10e406f 100644 --- a/src/url_template.c +++ b/src/url_template.c @@ -4,6 +4,7 @@ #include #include #include +#include #include typedef struct { @@ -39,6 +40,48 @@ static int copy_template_value(char *buffer, size_t buffer_size, const char *val return 0; } +static int query_param_name_equals(const char *param_start, const char *equals_pos, const char *name) { + size_t param_len; + + if (!param_start || !equals_pos || !name || equals_pos <= param_start) + return 0; + + param_len = (size_t)(equals_pos - param_start); + return strlen(name) == param_len && strncasecmp(param_start, name, param_len) == 0; +} + +static int is_seek_param_name_builtin(const char *name) { + if (!name) + return 0; + + return strcasecmp(name, "playseek") == 0 || strcasecmp(name, "tvdr") == 0; +} + +static int copy_selected_seek_param_name(url_template_analysis_t *analysis, const char *query_param_name, + int query_param_has_range, const char *explicit_seek_name) { + if (!analysis) + return -1; + + analysis->seek_param_name[0] = '\0'; + + if (!query_param_has_range || !query_param_name || query_param_name[0] == '\0') + return 0; + + if (explicit_seek_name && explicit_seek_name[0] != '\0') { + if (strcasecmp(query_param_name, explicit_seek_name) != 0) + return 0; + } else if (!is_seek_param_name_builtin(query_param_name)) { + return 0; + } + + if (strlen(query_param_name) >= sizeof(analysis->seek_param_name)) + return -1; + + strncpy(analysis->seek_param_name, query_param_name, sizeof(analysis->seek_param_name) - 1); + analysis->seek_param_name[sizeof(analysis->seek_param_name) - 1] = '\0'; + return 0; +} + static int classify_template_placeholder(const char *inner, int is_short_syntax, int *is_known_template, int *is_begin, int *is_end, int *needs_begin, int *needs_end) { char normalized[128]; @@ -369,11 +412,14 @@ static int template_path_requirements(const char *url, int *has_template, int *n static int template_query_requirements(const char *url, int *has_template, int *needs_begin, int *needs_end, char *begin_template, size_t begin_template_size, char *end_template, - size_t end_template_size) { + size_t end_template_size, char *query_param_name, size_t query_param_name_size, + int *query_param_has_range, char *explicit_seek_name, + size_t explicit_seek_name_size) { const char *query_start; const char *param_start; - if (!url || !has_template || !needs_begin || !needs_end || !begin_template || !end_template) + if (!url || !has_template || !needs_begin || !needs_end || !begin_template || !end_template || !query_param_name || + !query_param_has_range || !explicit_seek_name) return -1; *has_template = 0; @@ -381,6 +427,9 @@ static int template_query_requirements(const char *url, int *has_template, int * *needs_end = 0; begin_template[0] = '\0'; end_template[0] = '\0'; + query_param_name[0] = '\0'; + *query_param_has_range = 0; + explicit_seek_name[0] = '\0'; query_start = strchr(url, '?'); if (!query_start) { @@ -417,6 +466,11 @@ static int template_query_requirements(const char *url, int *has_template, int * value_start = equals_pos + 1; value_len = (size_t)(param_end - value_start); + if (!explicit_seek_name[0] && query_param_name_equals(param_start, equals_pos, "r2h-seek-name")) { + if (copy_template_value(explicit_seek_name, explicit_seek_name_size, value_start, value_len) != 0) + return -1; + } + if (template_substring_requirements(value_start, value_len, &local_has_template, &has_begin, &has_end, &local_needs_begin, &local_needs_end) != 0) return -1; @@ -435,6 +489,11 @@ static int template_query_requirements(const char *url, int *has_template, int * if (!end_template[0] && copy_template_value(end_template, end_template_size, separator + 1, (size_t)(param_end - (separator + 1))) != 0) return -1; + if (!query_param_name[0]) { + if (copy_template_value(query_param_name, query_param_name_size, param_start, + (size_t)(equals_pos - param_start)) == 0) + *query_param_has_range = 1; + } } } else if (has_begin && !has_end) { if (!begin_template[0] && copy_template_value(begin_template, begin_template_size, value_start, value_len) != 0) @@ -767,6 +826,9 @@ int url_template_analyze(const char *url, url_template_analysis_t *analysis) { char path_end_template[URL_TEMPLATE_FRAGMENT_SIZE]; char query_begin_template[URL_TEMPLATE_FRAGMENT_SIZE]; char query_end_template[URL_TEMPLATE_FRAGMENT_SIZE]; + char query_param_name[128]; + int query_param_has_range; + char explicit_seek_name[128]; if (!url || !analysis) return -1; @@ -778,12 +840,16 @@ int url_template_analyze(const char *url, url_template_analysis_t *analysis) { return -1; if (template_query_requirements(url, &query_has_template, &query_needs_begin, &query_needs_end, query_begin_template, - sizeof(query_begin_template), query_end_template, sizeof(query_end_template)) != 0) + sizeof(query_begin_template), query_end_template, sizeof(query_end_template), + query_param_name, sizeof(query_param_name), &query_param_has_range, + explicit_seek_name, sizeof(explicit_seek_name)) != 0) return -1; analysis->has_template = path_has_template || query_has_template; analysis->needs_begin = path_needs_begin || query_needs_begin; analysis->needs_end = path_needs_end || query_needs_end; + if (copy_selected_seek_param_name(analysis, query_param_name, query_param_has_range, explicit_seek_name) != 0) + return -1; if (query_begin_template[0]) { strncpy(analysis->begin_template, query_begin_template, sizeof(analysis->begin_template) - 1); diff --git a/src/url_template.h b/src/url_template.h index 5901dbe..dc680f8 100644 --- a/src/url_template.h +++ b/src/url_template.h @@ -40,6 +40,7 @@ struct url_template_analysis_s { int needs_end; char begin_template[URL_TEMPLATE_FRAGMENT_SIZE]; char end_template[URL_TEMPLATE_FRAGMENT_SIZE]; + char seek_param_name[128]; }; typedef struct url_template_analysis_s url_template_analysis_t;