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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions e2e/test_http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions e2e/test_rtsp_seek_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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).
Expand Down
62 changes: 62 additions & 0 deletions e2e/test_url_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions src/m3u.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
}
Expand Down
18 changes: 16 additions & 2 deletions src/service.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 */
Expand Down
72 changes: 69 additions & 3 deletions src/url_template.c
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>

typedef struct {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -369,18 +412,24 @@ 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;
*needs_begin = 0;
*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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/url_template.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading