Skip to content

Commit cd57d8b

Browse files
committed
Merge branch 'main' into hb-refactor-readme
2 parents b27696c + 7d469fb commit cd57d8b

5 files changed

Lines changed: 111 additions & 55 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,16 @@ The following options are available:
198198

199199
* `:allow_remote_access` - Tidewave only allows requests from localhost by default, even if your server listens on other interfaces, for security purposes. Read [our security guidelines for more information and when to allow remote access](https://hexdocs.pm/tidewave/security.html) (if you know what you are doing)
200200

201+
* `:allowed_origins` - a list of values matched against the `Origin` header to prevent cross origin and DNS rebinding attacks. Each value must be a string of shape `[scheme:]//host[:port]`, where both scheme and port are optional. The host may also start with "*". Example: `["//localhost:8000", "//*.test"]`
202+
201203
* `:inspect_opts` - custom options passed to `Kernel.inspect/2` when formatting some tool results. Defaults to: `[charlists: :as_lists, limit: 50, pretty: true]`
202204

203205
* `:team` - set your Tidewave Team configuration, such as `team: [id: "my-company"]`
204206

205207
* `:toolbar` - controls whether the Tidewave toolbar is injected into your HTML pages. Defaults to `true`
206208

209+
* `tmp_dir` - temporary directory Tidewave uses for screenshots and recordings. It must be a relative directory to the current application root. Defaults to `tmp`, storing files under `tmp/tidewave/screenshots` and `tmp/tidewave/recordings`
210+
207211
## License
208212

209213
Copyright (c) 2025 Dashbit

lib/tidewave.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ defmodule Tidewave do
3636
team: Keyword.get(opts, :team, []),
3737
toolbar: Keyword.get(opts, :toolbar, true),
3838
inspect_opts:
39-
Keyword.get(opts, :inspect_opts, charlists: :as_lists, limit: 50, pretty: true)
39+
Keyword.get(opts, :inspect_opts, charlists: :as_lists, limit: 50, pretty: true),
40+
tmp_dir: Keyword.get(opts, :tmp_dir, "tmp")
4041
}
4142
end
4243

@@ -184,7 +185,8 @@ defmodule Tidewave do
184185
framework_type: "phoenix",
185186
tidewave_version: package_version(:tidewave),
186187
team: Map.new(plug_config.team),
187-
local_port: Plug.Conn.get_sock_data(conn).port
188+
local_port: Plug.Conn.get_sock_data(conn).port,
189+
tmp_dir: plug_config.tmp_dir
188190
}
189191
end
190192

lib/tidewave/router.ex

Lines changed: 80 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -113,73 +113,106 @@ defmodule Tidewave.Router do
113113

114114
defp check_origin(conn, _opts) do
115115
case {conn.path_info, get_req_header(conn, "origin")} do
116+
# No origin header is always allowed
117+
{_, []} ->
118+
conn
119+
116120
# GET / allows any origin
117121
{[], _} ->
118122
conn
119123

120124
# /config contains metadata for discovery and it is safe to allow any origin
121-
{["config"], _} ->
125+
{[no_check_origin], _} when no_check_origin in ["config"] ->
122126
conn
123127

124-
# The control page WebSocket is the one endpoint that legitimately
125-
# receives an Origin header (browsers always send it on WebSocket
126-
# upgrades), so it gets a same-origin check instead.
127-
{["ws"], origin} ->
128-
require_same_origin(conn, origin)
129-
130-
# No origin header is always allowed
131-
{_, []} ->
132-
conn
128+
# /ws and /upload are acessed from the web app and must have origins
129+
{[check_origin], [origin]} when check_origin in ["ws", "upload"] ->
130+
if valid_allowed_origin?(conn, URI.parse(origin)) do
131+
conn
132+
else
133+
log_and_send_403(conn, """
134+
For security reasons, Tidewave only accepts requests from the same origin your web app is running on.
133135
134-
# Uploads are allowed from the same origin.
135-
{["upload"], origin} ->
136-
require_same_origin(conn, origin)
136+
If you really want to allow remote connections, configure the Tidewave with the `allowed_origins: [#{inspect(origin)}]` option.
137+
""")
138+
end
137139

138-
# /mcp refuses if origin header is set
140+
# /mcp and everything else fails if origin header is set
139141
{_, _} ->
140142
log_and_send_403(conn, """
141143
For security reasons, Tidewave does not accept requests with an origin header for this endpoint.
142144
""")
143145
end
144146
end
145147

146-
defp require_same_origin(conn, [origin | _]) do
147-
if origin_host(origin) in allowed_origin_hosts(conn.private.tidewave_config) do
148-
conn
149-
else
150-
log_and_send_403(conn, """
151-
For security reasons, this page only allows connections from the application's own origin.
152-
""")
153-
end
148+
defp valid_allowed_origin?(conn, origin) do
149+
allowed_origins =
150+
case conn.private.tidewave_config.allowed_origins do
151+
[_ | _] = allowed_origins -> allowed_origins
152+
_ -> [host_from_endpoint!(conn)]
153+
end
154+
155+
Enum.any?(allowed_origins, &allowed_origin?(origin, parse_allowed_origin!(&1)))
154156
end
155157

156-
defp origin_host(origin) do
157-
URI.parse(origin).host
158+
defp host_from_endpoint!(conn) do
159+
case conn.private do
160+
%{tidewave_config: %{phoenix_endpoint: endpoint}} when not is_nil(endpoint) ->
161+
"//#{host_from_endpoint(endpoint)}"
162+
163+
_ ->
164+
raise """
165+
no Phoenix endpoint found! You must manually configure the \
166+
allowed origins for Tidewave by setting the `:allowed_origins` \
167+
option on the Tidewave plug:
168+
169+
plug Tidewave, allowed_origins: ["//localhost"]
170+
"""
171+
end
158172
end
159173

160-
defp allowed_origin_hosts(%{allowed_origins: [_ | _] = allowed_origins}) do
161-
Enum.map(allowed_origins, &origin_or_host_to_host/1)
174+
defp host_from_endpoint(endpoint) do
175+
cond do
176+
function_exported?(endpoint, :struct_url, 0) ->
177+
endpoint.struct_url().host
178+
179+
function_exported?(endpoint, :config, 1) ->
180+
endpoint.config(:url)[:host]
181+
182+
true ->
183+
nil
184+
end || raise_missing_allowed_origins!()
162185
end
163186

164-
defp allowed_origin_hosts(%{phoenix_endpoint: endpoint}) when not is_nil(endpoint) do
165-
if host = endpoint.config(:url)[:host] do
166-
[host]
167-
else
168-
raise_missing_allowed_origins!()
187+
defp parse_allowed_origin!(origin) do
188+
case URI.parse(origin) do
189+
%URI{host: nil} ->
190+
raise ArgumentError,
191+
"invalid :allowed_origins value: #{inspect(origin)}. " <>
192+
"Expected an origin with a host that is parsable by URI.parse/1. For example: " <>
193+
"[\"https://example.com\", \"//another.com:888\", \"//other.com\"]"
194+
195+
%URI{} = uri ->
196+
uri
169197
end
170198
end
171199

172-
defp allowed_origin_hosts(_config) do
173-
raise_missing_allowed_origins!()
200+
defp allowed_origin?(origin, allowed) do
201+
compare?(origin.scheme, allowed.scheme) and
202+
compare?(origin.port, allowed.port) and
203+
compare_host?(origin.host, allowed.host)
174204
end
175205

176-
defp origin_or_host_to_host(origin_or_host) do
177-
case URI.parse(origin_or_host) do
178-
%URI{host: host} when is_binary(host) -> host
179-
_ -> origin_or_host
180-
end
206+
defp compare?(request_val, allowed_val) do
207+
is_nil(allowed_val) or request_val == allowed_val
181208
end
182209

210+
defp compare_host?(request_host, "*." <> allowed_host),
211+
do: request_host == allowed_host or String.ends_with?(request_host, "." <> allowed_host)
212+
213+
defp compare_host?(request_host, allowed_host),
214+
do: request_host == allowed_host
215+
183216
defp raise_missing_allowed_origins! do
184217
raise """
185218
Tidewave cannot verify the origin because no allowed origins are configured and no Phoenix endpoint URL host is available.
@@ -226,8 +259,9 @@ defmodule Tidewave.Router do
226259
with %{"type" => type, "file" => %Plug.Upload{} = upload}
227260
when type in @allowed_upload_types <- conn.body_params,
228261
true <- is_allowed_content_type?(upload) do
229-
create_upload_dir!(type)
230-
dest = upload_path(type, upload.filename)
262+
tmp_dir = conn.private.tidewave_config.tmp_dir
263+
create_upload_dir!(tmp_dir, type)
264+
dest = upload_path(tmp_dir, type, upload.filename)
231265
File.cp!(upload.path, dest)
232266

233267
conn
@@ -243,21 +277,17 @@ defmodule Tidewave.Router do
243277
end
244278
end
245279

246-
defp create_upload_dir!(type) do
247-
File.mkdir_p!(upload_dir(type))
248-
end
249-
250-
defp tmp_dir do
251-
Application.get_env(:tidewave, :tmp_dir, "tmp")
280+
defp create_upload_dir!(tmp_dir, type) do
281+
File.mkdir_p!(upload_dir(tmp_dir, type))
252282
end
253283

254-
defp upload_dir(type) do
255-
tmp_dir()
284+
defp upload_dir(tmp_dir, type) do
285+
tmp_dir
256286
|> Path.join("tidewave")
257287
|> Path.join(folder_for_type(type))
258288
end
259289

260-
defp upload_path(type, filename) do
290+
defp upload_path(tmp_dir, type, filename) do
261291
ext = filename |> Path.extname() |> String.downcase()
262292

263293
cond do
@@ -268,7 +298,7 @@ defmodule Tidewave.Router do
268298
raise "filename must have a valid extension (.png, .jpg, .jpeg, .webm): #{filename}"
269299

270300
true ->
271-
Path.join(upload_dir(type), filename)
301+
Path.join(upload_dir(tmp_dir, type), filename)
272302
end
273303
end
274304

test/control_test.exs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ defmodule Tidewave.ControlPlaneTest do
3737
conn =
3838
ws_conn()
3939
|> Plug.Conn.put_req_header("origin", "http://control.example.com")
40-
|> Tidewave.call(Tidewave.init(allowed_origins: ["control.example.com"]))
40+
|> Tidewave.call(Tidewave.init(allowed_origins: ["//control.example.com"]))
4141

4242
assert conn.status == nil
4343
end
@@ -51,11 +51,20 @@ defmodule Tidewave.ControlPlaneTest do
5151
assert conn.status == nil
5252
end
5353

54-
test "allows a websocket upgrade from the same host as an allowed full origin" do
54+
test "allows a websocket upgrade from the same host as an allowed port-less origin" do
5555
conn =
5656
ws_conn()
5757
|> Plug.Conn.put_req_header("origin", "http://control.example.com:5173")
58-
|> Tidewave.call(Tidewave.init(allowed_origins: ["http://control.example.com:4000"]))
58+
|> Tidewave.call(Tidewave.init(allowed_origins: ["//control.example.com"]))
59+
60+
assert conn.status == nil
61+
end
62+
63+
test "allows a websocket upgrade from a wildcard origin" do
64+
conn =
65+
ws_conn()
66+
|> Plug.Conn.put_req_header("origin", "http://control.example.com")
67+
|> Tidewave.call(Tidewave.init(allowed_origins: ["//*.example.com"]))
5968

6069
assert conn.status == nil
6170
end
@@ -97,6 +106,16 @@ defmodule Tidewave.ControlPlaneTest do
97106
|> Tidewave.call(Tidewave.init([]))
98107
end
99108
end
109+
110+
test "raises on invalid allowed origin configuration" do
111+
assert_raise ArgumentError,
112+
~r/invalid :allowed_origins value.*Expected an origin with a host/,
113+
fn ->
114+
ws_conn()
115+
|> Plug.Conn.put_req_header("origin", "http://app.example.com")
116+
|> Tidewave.call(Tidewave.init(allowed_origins: ["invalid-origin"]))
117+
end
118+
end
100119
end
101120

102121
defp get_resp_header_value(conn, key) do

test/tidewave_test.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ defmodule TidewaveTest do
159159
"framework_type" => "phoenix",
160160
"project_name" => "tidewave",
161161
"team" => %{},
162+
"tmp_dir" => "tmp",
162163
"tidewave_version" => _
163164
} = Jason.decode!(conn.resp_body)
164165
end

0 commit comments

Comments
 (0)