Skip to content

Commit 14a1795

Browse files
Add RFC 2617 qop support and fix SETUP URL handling (#58)
* Add RFC 2617 qop support and fix SETUP URL handling * less Map.put/get --------- Co-authored-by: George Alexander Day <georgealexanderday@proton.me>
1 parent cd823c7 commit 14a1795

File tree

4 files changed

+170
-11
lines changed

4 files changed

+170
-11
lines changed

lib/membrane_rtsp/request.ex

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,17 @@ defmodule Membrane.RTSP.Request do
172172
defp apply_path(%URI{} = base_url, %__MODULE__{path: nil}), do: base_url
173173

174174
defp apply_path(%URI{} = base_url, %__MODULE__{path: path}) do
175-
URI.parse(path)
176-
|> Map.get(:path)
177-
|> Path.relative_to(base_url.path)
178-
|> then(&Path.join(base_url.path, &1))
179-
|> then(&Map.put(base_url, :path, &1))
180-
|> URI.to_string()
175+
%URI{} = parsed = URI.parse(path)
176+
177+
if parsed.host do
178+
%URI{parsed | userinfo: nil} |> URI.to_string()
179+
else
180+
parsed.path
181+
|> Path.relative_to(base_url.path)
182+
|> then(&Path.join(base_url.path, &1))
183+
|> then(&%URI{base_url | path: &1, query: nil})
184+
|> URI.to_string()
185+
end
181186
end
182187

183188
defp render_headers([]), do: ""

lib/membrane_rtsp/rtsp.ex

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ defmodule Membrane.RTSP do
2121
@moduledoc false
2222
@type digest_opts() :: %{
2323
realm: String.t() | nil,
24-
nonce: String.t() | nil
24+
nonce: String.t() | nil,
25+
qop: String.t() | nil,
26+
nc: non_neg_integer()
2527
}
2628

2729
@type auth() :: nil | :basic | {:digest, digest_opts()}
@@ -269,10 +271,17 @@ defmodule Membrane.RTSP do
269271
{:ok, state} <- handle_session_id(parsed_response, state),
270272
{:ok, %State{} = state} <- detect_authentication_type(parsed_response, state) do
271273
state = %State{state | cseq: state.cseq + 1}
274+
state = increment_nc(state)
272275
{:ok, parsed_response, state}
273276
end
274277
end
275278

279+
defp increment_nc(%State{auth: {:digest, %{nc: nc} = opts}} = state) do
280+
%State{state | auth: {:digest, %{opts | nc: nc + 1}}}
281+
end
282+
283+
defp increment_nc(state), do: state
284+
276285
@spec handle_execute_call(Request.t(), boolean(), State.t()) ::
277286
{:reply, Response.result(), State.t()}
278287
defp handle_execute_call(request, retry, state) do
@@ -311,6 +320,11 @@ defmodule Membrane.RTSP do
311320
encoded_uri = Request.process_uri(request, uri)
312321
ha1 = md5([username, options.realm, password])
313322
ha2 = md5([request.method, encoded_uri])
323+
324+
encode_digest_auth(username, encoded_uri, ha1, ha2, options)
325+
end
326+
327+
defp encode_digest_auth(username, encoded_uri, ha1, ha2, %{qop: nil} = options) do
314328
response = md5([ha1, options.nonce, ha2])
315329

316330
Enum.join(
@@ -326,6 +340,29 @@ defmodule Membrane.RTSP do
326340
)
327341
end
328342

343+
defp encode_digest_auth(username, encoded_uri, ha1, ha2, %{qop: qop} = options) do
344+
nc = options[:nc] || 1
345+
nc_hex = :io_lib.format("~8.16.0b", [nc]) |> IO.iodata_to_binary()
346+
cnonce = :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
347+
response = md5([ha1, options.nonce, nc_hex, cnonce, qop, ha2])
348+
349+
"Digest " <>
350+
Enum.join(
351+
[
352+
~s(username="#{username}"),
353+
~s(realm="#{options.realm}"),
354+
~s(nonce="#{options.nonce}"),
355+
~s(uri="#{encoded_uri}"),
356+
~s(response="#{response}"),
357+
~s(algorithm=MD5),
358+
~s(qop=#{qop}),
359+
~s(nc=#{nc_hex}),
360+
~s(cnonce="#{cnonce}")
361+
],
362+
","
363+
)
364+
end
365+
329366
@spec md5([String.t()]) :: String.t()
330367
defp md5(value) do
331368
value
@@ -359,7 +396,14 @@ defmodule Membrane.RTSP do
359396
with {:ok, "Digest " <> digest} <- Response.get_header(response, "WWW-Authenticate") do
360397
[_match, nonce] = Regex.run(~r/nonce=\"(?<nonce>.*)\"/U, digest)
361398
[_match, realm] = Regex.run(~r/realm=\"(?<realm>.*)\"/U, digest)
362-
auth_options = {:digest, %{nonce: nonce, realm: realm}}
399+
400+
qop =
401+
case Regex.run(~r/qop=\"?([^",]+)\"?/, digest) do
402+
[_match, qop_value] -> qop_value
403+
nil -> nil
404+
end
405+
406+
auth_options = {:digest, %{nonce: nonce, realm: realm, qop: qop, nc: 1}}
363407
{:ok, %{state | auth: auth_options}}
364408
else
365409
# non digest auth?

test/membrane_rtsp/request_test.exs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,62 @@ defmodule Membrane.RTSP.RequestTest do
4343
Factory.SampleOptionsRequest.request()
4444
|> Request.stringify(Factory.SampleOptionsRequest.url())
4545
end
46+
47+
test "strips query string from base URI when path is relative" do
48+
# VIVOTEK cameras include query params in stream URI that should not appear in SETUP
49+
uri = "rtsp://192.168.1.196:554/media2/stream.sdp?profile=Profile200"
50+
51+
expected_result = """
52+
SETUP rtsp://192.168.1.196:554/media2/stream.sdp/trackID=2 RTSP/1.0
53+
CSeq: 4
54+
Transport: RTP/AVP;unicast;client_port=57614-57615
55+
"""
56+
57+
%Request{
58+
method: "SETUP",
59+
headers: [{"CSeq", "4"}, {"Transport", "RTP/AVP;unicast;client_port=57614-57615"}],
60+
path: "trackID=2"
61+
}
62+
|> assert_rendered_request(expected_result, uri)
63+
end
64+
65+
test "uses absolute URL directly when path is absolute (RFC 2326)" do
66+
# Bosch cameras return absolute control URLs in SDP
67+
base_uri = "rtsp://192.168.1.44:554/rtsp_tunnel"
68+
69+
absolute_control =
70+
"rtsp://192.168.1.44:554/rtsp_tunnel?p=0&line=1&inst=1&vcd=2&stream=video"
71+
72+
expected_result = """
73+
SETUP rtsp://192.168.1.44:554/rtsp_tunnel?p=0&line=1&inst=1&vcd=2&stream=video RTSP/1.0
74+
CSeq: 4
75+
Transport: RTP/AVP;unicast;client_port=57614-57615
76+
"""
77+
78+
%Request{
79+
method: "SETUP",
80+
headers: [{"CSeq", "4"}, {"Transport", "RTP/AVP;unicast;client_port=57614-57615"}],
81+
path: absolute_control
82+
}
83+
|> assert_rendered_request(expected_result, base_uri)
84+
end
85+
86+
test "strips userinfo from absolute control URL" do
87+
# Absolute URLs should not leak credentials
88+
base_uri = "rtsp://user:pass@192.168.1.44:554/rtsp_tunnel"
89+
absolute_control = "rtsp://user:pass@192.168.1.44:554/rtsp_tunnel?p=0&stream=video"
90+
91+
request = %Request{
92+
method: "SETUP",
93+
headers: [],
94+
path: absolute_control
95+
}
96+
97+
result = Request.stringify(request, URI.parse(base_uri))
98+
99+
refute String.contains?(result, "user:pass")
100+
assert String.contains?(result, "rtsp://192.168.1.44:554/rtsp_tunnel?p=0&stream=video")
101+
end
46102
end
47103

48104
defp assert_rendered_request(request, expected_result, uri_string) do

test/membrane_rtsp/session/session_logic_test.exs

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,35 @@ defmodule Membrane.RTSP.SessionLogicTest do
139139

140140
assert {:reply, {:ok, _response}, state} = RTSP.handle_call({:execute, request}, nil, state)
141141

142-
assert state.auth == {:digest, %{nonce: "nonce", realm: "realm"}}
142+
assert state.auth == {:digest, %{nonce: "nonce", realm: "realm", qop: nil, nc: 2}}
143143
end
144144

145-
test "digest auth", %{state: %State{} = state, request: request} do
145+
test "add digest information with qop in the state (RFC 2617)", %{
146+
state: state,
147+
request: request
148+
} do
149+
mock(:gen_tcp, [send: 2], fn _socket, _request ->
150+
{:ok,
151+
"RTSP/1.0 200 OK\r\nWWW-Authenticate: Digest realm=\"VIVOTEK\", nonce=\"abc123\", qop=\"auth\"\r\n\r\n"}
152+
end)
153+
154+
assert {:reply, {:ok, _response}, state} = RTSP.handle_call({:execute, request}, nil, state)
155+
156+
assert state.auth == {:digest, %{nonce: "abc123", realm: "VIVOTEK", qop: "auth", nc: 2}}
157+
end
158+
159+
test "add digest information with unquoted qop", %{state: state, request: request} do
160+
mock(:gen_tcp, [send: 2], fn _socket, _request ->
161+
{:ok,
162+
"RTSP/1.0 200 OK\r\nWWW-Authenticate: Digest realm=\"test\", nonce=\"xyz\", qop=auth\r\n\r\n"}
163+
end)
164+
165+
assert {:reply, {:ok, _response}, state} = RTSP.handle_call({:execute, request}, nil, state)
166+
167+
assert state.auth == {:digest, %{nonce: "xyz", realm: "test", qop: "auth", nc: 2}}
168+
end
169+
170+
test "digest auth without qop (RFC 2069)", %{state: %State{} = state, request: request} do
146171
credentials = "login:password"
147172

148173
mock(:gen_tcp, [send: 2], fn _socket, serialized_request ->
@@ -155,13 +180,42 @@ defmodule Membrane.RTSP.SessionLogicTest do
155180
end)
156181

157182
parsed_uri = URI.parse("rtsp://#{credentials}@localhost:5554/vod/mp4:name.mov")
158-
digest_auth_options = {:digest, %{nonce: "nonce", realm: "realm"}}
183+
digest_auth_options = {:digest, %{nonce: "nonce", realm: "realm", qop: nil, nc: 1}}
159184

160185
state = %State{state | uri: parsed_uri, auth: digest_auth_options}
161186

162187
assert {:reply, {:ok, _response}, _state} = RTSP.handle_call({:execute, request}, nil, state)
163188
end
164189

190+
test "digest auth with qop (RFC 2617)", %{state: %State{} = state, request: request} do
191+
credentials = "login:password"
192+
193+
mock(:gen_tcp, [send: 2], fn _socket, serialized_request ->
194+
# QOP auth includes: algorithm, qop, nc (8-digit hex), cnonce
195+
assert String.contains?(serialized_request, "Authorization: Digest ")
196+
assert String.contains?(serialized_request, "username=\"login\"")
197+
assert String.contains?(serialized_request, "realm=\"realm\"")
198+
assert String.contains?(serialized_request, "nonce=\"nonce\"")
199+
assert String.contains?(serialized_request, "algorithm=MD5")
200+
assert String.contains?(serialized_request, "qop=auth")
201+
assert String.contains?(serialized_request, "nc=00000001")
202+
assert String.contains?(serialized_request, "cnonce=")
203+
204+
mock_response(serialized_request)
205+
end)
206+
207+
parsed_uri = URI.parse("rtsp://#{credentials}@localhost:5554/vod/mp4:name.mov")
208+
digest_auth_options = {:digest, %{nonce: "nonce", realm: "realm", qop: "auth", nc: 1}}
209+
210+
state = %State{state | uri: parsed_uri, auth: digest_auth_options}
211+
212+
assert {:reply, {:ok, _response}, new_state} =
213+
RTSP.handle_call({:execute, request}, nil, state)
214+
215+
# nc should increment after successful request
216+
assert {:digest, %{nc: 2}} = new_state.auth
217+
end
218+
165219
defp mock_response(request) do
166220
[_line, rest] = String.split(request, "\r\n", parts: 2)
167221
{:ok, @response_header <> rest}

0 commit comments

Comments
 (0)