Skip to content

Commit 42209cf

Browse files
committed
video: close remaining credential downgrade paths
Reject duplicate RTMP connect properties, preserve the first credential across RTMP setup commands, and continue validating credentials on every RTSP request. Bound RTSP framing, reject ambiguous content lengths, and cover the reported downgrade cases with integration tests.
1 parent a9ab58d commit 42209cf

6 files changed

Lines changed: 281 additions & 9 deletions

File tree

httpreq.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,36 @@ std::string HttpRequest::header(const char *name) const
110110
return "";
111111
}
112112

113+
size_t HttpRequest::header_count(const char *name) const
114+
{
115+
const std::string want = lower(name);
116+
size_t count = 0;
117+
size_t pos = 0;
118+
while (pos < headers_.size()) {
119+
size_t eol = headers_.find('\n', pos);
120+
if (eol == std::string::npos) {
121+
eol = headers_.size();
122+
}
123+
std::string line = headers_.substr(pos, eol - pos);
124+
if (!line.empty() && line.back() == '\r') {
125+
line.pop_back();
126+
}
127+
const size_t colon = line.find(':');
128+
if (colon != std::string::npos) {
129+
std::string field = line.substr(0, colon);
130+
const size_t end = field.find_last_not_of(" \t");
131+
if (end != std::string::npos) {
132+
field.resize(end + 1);
133+
}
134+
if (lower(field) == want) {
135+
count++;
136+
}
137+
}
138+
pos = eol + 1;
139+
}
140+
return count;
141+
}
142+
113143
std::string HttpRequest::query(const char *name) const
114144
{
115145
std::string value;

httpreq.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class HttpRequest {
3232
// Header lookup, case-insensitive. Empty string when absent.
3333
std::string header(const char *name) const;
3434

35+
// Number of occurrences of a header, case-insensitive. Security
36+
// parsers use this to reject ambiguous framing fields.
37+
size_t header_count(const char *name) const;
38+
3539
// Query parameter from the request target. Empty when absent.
3640
std::string query(const char *name) const;
3741

tests/rtmp_client.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ def _amf_null():
2929

3030
def _amf_obj(d):
3131
out = b'\x03'
32-
for k, v in d.items():
32+
items = d.items() if hasattr(d, 'items') else d
33+
for k, v in items:
3334
kb = k.encode()
3435
out += struct.pack('>H', len(kb)) + kb
3536
out += _amf_str(v) if isinstance(v, str) else _amf_num(v)
@@ -117,7 +118,7 @@ def handshake(self):
117118
self.s.sendall(got[1:1537]) # C2 echoes S1
118119
return got
119120

120-
def connect(self, password=None):
121+
def connect(self, password=None, properties=None):
121122
stream = self.stream
122123
if password:
123124
stream = '%s?pw=%s' % (stream, password)
@@ -127,9 +128,11 @@ def connect(self, password=None):
127128
# told otherwise.
128129
self.s.sendall(self._chunk(2, 1, 0, 0,
129130
self.out_chunk.to_bytes(4, 'big')))
131+
if properties is None:
132+
properties = {'app': self.app, 'tcUrl': tc,
133+
'flashVer': 'test'}
130134
self.s.sendall(self._command(
131-
_amf_str('connect') + _amf_num(1) +
132-
_amf_obj({'app': self.app, 'tcUrl': tc, 'flashVer': 'test'})))
135+
_amf_str('connect') + _amf_num(1) + _amf_obj(properties)))
133136
self._drain()
134137
self.s.sendall(self._command(
135138
_amf_str('createStream') + _amf_num(2) + _amf_null()))
@@ -138,6 +141,12 @@ def connect(self, password=None):
138141
_amf_str('publish') + _amf_num(3) + _amf_null() +
139142
_amf_str(stream) + _amf_str('live')))
140143

144+
def fcpublish(self, stream):
145+
self.s.sendall(self._command(
146+
_amf_str('FCPublish') + _amf_num(0) + _amf_null() +
147+
_amf_str(stream)))
148+
self._drain()
149+
141150
def publish(self, first_tags=(), pipeline=False):
142151
"""Send publish. With pipeline, media rides the same write.
143152

tests/test_video_rtsp.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,9 @@ class TestPublishPassword:
388388
"""
389389

390390
def test_accepted_with_no_mavlink_session_at_all(self, session, clip):
391+
# ffmpeg later resolves the SDP control URI as
392+
# ?pw=pubsecret/streamid=0. Reaching join=ready therefore also
393+
# covers the guard's exact-password-plus-control-path handling.
391394
s = session(with_mav=False, publish_pass='pubsecret')
392395
s.pub = _publish_with('?pw=pubsecret', clip)
393396
assert s.proxy.wait_for(r'RTSP publisher', timeout=25), s.proxy.log
@@ -528,6 +531,43 @@ def test_fragmented_request_line_waits_for_the_credential(self, session):
528531
finally:
529532
sock.close()
530533

534+
def test_wrong_password_on_later_rtsp_request_is_refused(self, session):
535+
"""Session fallback on OPTIONS must not hide a credential later."""
536+
s = session(with_mav=True, publish_pass='pubsecret', session_ok=True)
537+
sock = socket.create_connection(('127.0.0.1', VPORT), 5)
538+
sock.settimeout(10)
539+
try:
540+
sock.sendall(
541+
('OPTIONS rtsp://127.0.0.1:%d/cam RTSP/1.0\r\n'
542+
'CSeq: 1\r\n\r\n' % VPORT).encode())
543+
assert b'RTSP/1.0 200' in sock.recv(4096)
544+
sock.sendall(
545+
('ANNOUNCE rtsp://127.0.0.1:%d/cam?pw=wrong RTSP/1.0\r\n'
546+
'CSeq: 2\r\nContent-Length: 0\r\n\r\n' % VPORT).encode())
547+
assert s.proxy.wait_for(r'wrong publish password', timeout=10), \
548+
s.proxy.log
549+
finally:
550+
sock.close()
551+
552+
def test_ambiguous_rtsp_body_length_is_refused(self, session):
553+
"""A framing disagreement must not hide a later credential."""
554+
s = session(with_mav=True, publish_pass='pubsecret', session_ok=True)
555+
sock = socket.create_connection(('127.0.0.1', VPORT), 5)
556+
sock.settimeout(10)
557+
try:
558+
sock.sendall(
559+
('OPTIONS rtsp://127.0.0.1:%d/cam RTSP/1.0\r\n'
560+
'CSeq: 1\r\n\r\n' % VPORT).encode())
561+
assert b'RTSP/1.0 200' in sock.recv(4096)
562+
sock.sendall(
563+
b'ANNOUNCE rtsp://127.0.0.1/cam RTSP/1.0\r\n'
564+
b'CSeq: 2\r\nContent-Length: 64\r\n'
565+
b'Content-Length: 0\r\n\r\n')
566+
assert s.proxy.wait_for(r'RTSP publisher gone', timeout=10), \
567+
s.proxy.log
568+
finally:
569+
sock.close()
570+
531571
def test_offering_none_falls_back_on_a_flagged_slot(self, session, clip):
532572
s = session(with_mav=True, publish_pass='pubsecret', session_ok=True)
533573
s.pub = _publish_with('', clip)
@@ -1090,6 +1130,36 @@ def test_supplied_rtmp_password_cannot_become_absent(self, tmp_path,
10901130
pub.close()
10911131
s.stop()
10921132

1133+
@pytest.mark.parametrize('source', ['duplicate_app', 'fcpublish'])
1134+
def test_earlier_rtmp_password_cannot_be_erased(self, tmp_path, source):
1135+
"""Every pre-publish credential source preserves explicit presence."""
1136+
wd = _workdir(tmp_path, publish_pass='secret', session_ok=True)
1137+
s = RtspSession(wd, with_mav=True)
1138+
pub = None
1139+
try:
1140+
pub = rtmp_client.RtmpPublisher(
1141+
'127.0.0.1', VPORT, app='PhoenixFPV', stream='FPV')
1142+
pub.handshake()
1143+
if source == 'duplicate_app':
1144+
pub.connect(properties=[
1145+
('app', 'PhoenixFPV?pw=wrong'),
1146+
('app', 'PhoenixFPV'),
1147+
('tcUrl', 'rtmp://127.0.0.1/PhoenixFPV'),
1148+
])
1149+
assert s.proxy.wait_for(r'duplicate connect property',
1150+
timeout=15), s.proxy.log
1151+
else:
1152+
pub.connect()
1153+
pub.fcpublish('FPV?pw=wrong')
1154+
pub.publish()
1155+
assert s.proxy.wait_for(r'wrong publish password',
1156+
timeout=15), s.proxy.log
1157+
assert 'RTMP publishing' not in s.proxy.log
1158+
finally:
1159+
if pub:
1160+
pub.close()
1161+
s.stop()
1162+
10931163
def test_no_orphan_backend_after_the_rtmp_publisher_leaves(
10941164
self, tmp_path, clip):
10951165
s = RtspSession(_workdir(tmp_path, rtmp_path='PhoenixFPV/FPV'))

video.cpp

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010
#include "video.h"
1111

12+
#include <algorithm>
1213
#include <initializer_list>
1314
#include <memory>
1415
#include <vector>
@@ -116,6 +117,19 @@ struct SpliceQueue {
116117
// instead of this process buffering without limit.
117118
#define SPLICE_QUEUE_MAX (1u * 1024 * 1024)
118119

120+
/*
121+
RTSP is relayed byte-for-byte, but admission credentials can occur on
122+
any request URI in the session. Hold only the current request header
123+
long enough to inspect its request line. Bodies and interleaved RTP are
124+
counted and streamed without interpretation.
125+
*/
126+
struct RtspRequestGuard {
127+
std::vector<uint8_t> buffered;
128+
size_t opaque_left = 0;
129+
130+
void clear(void) { buffered.clear(); opaque_left = 0; }
131+
};
132+
119133
/*
120134
One RTMP handshake that has not published yet.
121135
@@ -155,6 +169,7 @@ struct Slot {
155169
int rtsp_client_fd = -1;
156170
SpliceQueue to_backend; // bytes read from the client, owed to ffmpeg
157171
SpliceQueue to_client; // and the other way
172+
RtspRequestGuard rtsp_guard;
158173
/*
159174
The RTMP session that owns the slot, once one has published and
160175
been admitted. Null until then.
@@ -227,6 +242,8 @@ class VideoChild {
227242
splice_proto_t proto);
228243
void close_rtsp(Slot &s, int idx, const char *why);
229244
bool pump_rtsp(Slot &s, int idx, int fd, time_t now);
245+
bool guard_rtsp_requests(Slot &s, int idx, const uint8_t *buf, size_t n,
246+
time_t now);
230247
bool pump_rtmp(Slot &s, int idx, int fd, time_t now);
231248
bool rtmp_start_backend(Slot &s, int idx);
232249
bool rtmp_drain_owner(Slot &s, int idx, bool alive);
@@ -691,6 +708,7 @@ void VideoChild::handle_rtsp(Slot &s, int idx, int fd,
691708
}
692709
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
693710
s.rtsp_client_fd = fd;
711+
s.rtsp_guard.clear();
694712
s.pub_ip_be = uint32_t(from.sin_addr.s_addr);
695713
s.pub_port_be = from.sin_port;
696714
latch_publisher(s, idx, now);
@@ -734,6 +752,7 @@ void VideoChild::close_rtsp(Slot &s, int idx, const char *why)
734752
s.rtmp.reset();
735753
s.to_backend.clear();
736754
s.to_client.clear();
755+
s.rtsp_guard.clear();
737756
s.rtsp.stop();
738757
s.rec.close_segment();
739758
s.recording = false;
@@ -778,6 +797,119 @@ bool VideoChild::splice_flush(int to_fd, SpliceQueue &q)
778797
return true;
779798
}
780799

800+
/*
801+
Filter the client-to-backend half of an RTSP splice. The first request
802+
was already checked before the backend started, but a client can put a
803+
credential on a later ANNOUNCE. Without continued inspection an absent
804+
OPTIONS could take session fallback and a later wrong password would be
805+
silently ignored.
806+
*/
807+
bool VideoChild::guard_rtsp_requests(Slot &s, int idx, const uint8_t *buf,
808+
size_t n, time_t now)
809+
{
810+
RtspRequestGuard &g = s.rtsp_guard;
811+
g.buffered.insert(g.buffered.end(), buf, buf + n);
812+
813+
while (!g.buffered.empty()) {
814+
if (g.opaque_left > 0) {
815+
const size_t take = std::min(g.opaque_left, g.buffered.size());
816+
s.to_backend.buf.insert(s.to_backend.buf.end(),
817+
g.buffered.begin(),
818+
g.buffered.begin() + long(take));
819+
g.buffered.erase(g.buffered.begin(),
820+
g.buffered.begin() + long(take));
821+
g.opaque_left -= take;
822+
continue;
823+
}
824+
825+
// Interleaved RTP/RTCP: '$', channel, 16-bit big-endian length.
826+
if (g.buffered[0] == '$') {
827+
if (g.buffered.size() < 4) {
828+
return true;
829+
}
830+
g.opaque_left = (size_t(g.buffered[2]) << 8) | g.buffered[3];
831+
s.to_backend.buf.insert(s.to_backend.buf.end(),
832+
g.buffered.begin(),
833+
g.buffered.begin() + 4);
834+
g.buffered.erase(g.buffered.begin(), g.buffered.begin() + 4);
835+
continue;
836+
}
837+
838+
size_t header_len = 0;
839+
for (size_t i = 0; i + 1 < g.buffered.size(); i++) {
840+
if (g.buffered[i] == '\n' && g.buffered[i + 1] == '\n') {
841+
header_len = i + 2;
842+
break;
843+
}
844+
if (i + 3 < g.buffered.size()
845+
&& g.buffered[i] == '\r' && g.buffered[i + 1] == '\n'
846+
&& g.buffered[i + 2] == '\r'
847+
&& g.buffered[i + 3] == '\n') {
848+
header_len = i + 4;
849+
break;
850+
}
851+
}
852+
if (header_len == 0) {
853+
return g.buffered.size() <= HTTP_MAX_REQUEST;
854+
}
855+
856+
HttpRequest req;
857+
if (req.feed(g.buffered.data(), header_len) != 1) {
858+
return false;
859+
}
860+
std::string pw;
861+
if (http_query_value(req.target(), "pw", pw)) {
862+
bool have_pw = false;
863+
for (uint8_t b : ke_.video_publish_key) {
864+
have_pw |= b != 0;
865+
}
866+
bool matches = video_password_matches(ke_.video_publish_key, pw);
867+
/*
868+
ffmpeg resolves an SDP control path after the whole source
869+
URI, producing e.g. ?pw=secret/streamid=0. URI syntax makes
870+
that suffix part of the query value. Accept it only when a
871+
slash-delimited prefix is itself the exact password; the
872+
peer still has to know the configured credential.
873+
*/
874+
const size_t slash = pw.rfind('/');
875+
if (!matches && slash != std::string::npos) {
876+
matches = video_password_matches(
877+
ke_.video_publish_key, pw.substr(0, slash));
878+
}
879+
if (have_pw && !matches) {
880+
log_reject(s, idx, s.pub_ip_be, VIDEO_ADMIT_BAD_PASSWORD,
881+
now);
882+
return false;
883+
}
884+
}
885+
886+
const size_t content_length_count =
887+
req.header_count("Content-Length");
888+
const std::string content_length = req.header("Content-Length");
889+
if (content_length_count > 1
890+
|| (content_length_count == 1 && content_length.empty())) {
891+
return false;
892+
}
893+
if (!content_length.empty()) {
894+
char *end = nullptr;
895+
errno = 0;
896+
const unsigned long long body =
897+
strtoull(content_length.c_str(), &end, 10);
898+
if (errno != 0 || end == content_length.c_str() || *end != '\0'
899+
|| body > 16u * 1024u * 1024u) {
900+
return false;
901+
}
902+
g.opaque_left = size_t(body);
903+
}
904+
s.to_backend.buf.insert(s.to_backend.buf.end(),
905+
g.buffered.begin(),
906+
g.buffered.begin() + long(header_len));
907+
g.buffered.erase(g.buffered.begin(),
908+
g.buffered.begin() + long(header_len));
909+
}
910+
return true;
911+
}
912+
781913
// EPOLLOUT only while something is queued. Armed unconditionally it
782914
// would make epoll_wait return immediately for ever on an idle splice,
783915
// which is the same idle-viewer CPU burn measured earlier.
@@ -1156,7 +1288,13 @@ bool VideoChild::pump_rtsp(Slot &s, int idx, int fd, time_t now)
11561288
return false;
11571289
}
11581290
if (n > 0) {
1159-
out.buf.insert(out.buf.end(), buf, buf + n);
1291+
if (fd == s.rtsp_client_fd) {
1292+
if (!guard_rtsp_requests(s, idx, buf, size_t(n), now)) {
1293+
return false;
1294+
}
1295+
} else {
1296+
out.buf.insert(out.buf.end(), buf, buf + n);
1297+
}
11601298
if (!splice_flush(to_fd, out)) {
11611299
return false;
11621300
}

0 commit comments

Comments
 (0)