Skip to content

Commit f51b219

Browse files
authored
[Bugfix] Improve OPML route security (#535)
* WIP - moved plugs; set up a new token-protected route plug * Added a route_token column to settings model * Hooked up token_protected_route plug to database * Hooked up new OPML route to UI; turned RSS and OPML feed buttons into links * Docs, tests * Added a note about the phoenix bug
1 parent 246ca3b commit f51b219

File tree

12 files changed

+295
-158
lines changed

12 files changed

+295
-158
lines changed

lib/pinchflat/settings/setting.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ defmodule Pinchflat.Settings.Setting do
3131
field :apprise_version, :string
3232
field :apprise_server, :string
3333
field :youtube_api_key, :string
34+
field :route_token, :string
3435

3536
field :video_codec_preference, :string
3637
field :audio_codec_preference, :string

lib/pinchflat_web/controllers/sources/source_html.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ defmodule PinchflatWeb.Sources.SourceHTML do
4040
end
4141

4242
def rss_feed_url(conn, source) do
43+
# NOTE: The reason for this concatenation is to avoid what appears to be a bug in Phoenix
44+
# See: https://github.com/phoenixframework/phoenix/issues/6033
4345
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
4446
end
4547

4648
def opml_feed_url(conn) do
47-
url(conn, ~p"/sources/opml") <> ".xml"
49+
url(conn, ~p"/sources/opml.xml?#{[route_token: Settings.get!(:route_token)]}")
4850
end
4951

5052
def output_path_template_override_placeholders(media_profiles) do

lib/pinchflat_web/controllers/sources/source_html/actions_dropdown.html.heex

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
22
<:option>
3-
<span
4-
x-data="{ copied: false }"
5-
x-on:click={"
6-
copyWithCallbacks(
7-
'#{rss_feed_url(@conn, @source)}',
8-
() => copied = true,
9-
() => copied = false
10-
)
11-
"}
12-
>
3+
<.link href={rss_feed_url(@conn, @source)} x-data="{ copied: false }" x-on:click={~s"
4+
$event.preventDefault();
5+
copyWithCallbacks(
6+
'#{rss_feed_url(@conn, @source)}',
7+
() => copied = true,
8+
() => copied = false
9+
)
10+
"}>
1311
Copy RSS Feed
1412
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
15-
</span>
13+
</.link>
1614
</:option>
1715
<:option>
1816
<span x-data="{ copied: false }" x-on:click={~s"

lib/pinchflat_web/controllers/sources/source_html/index.html.heex

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
22
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
3-
<nav>
4-
<.button color="bg-transparent" x-data="{ copied: false }" x-on:click={~s"
3+
<nav class="flex items-center justify-between gap-6">
4+
<.link href={opml_feed_url(@conn)} x-data="{ copied: false }" x-on:click={~s"
5+
$event.preventDefault();
56
copyWithCallbacks(
67
'#{opml_feed_url(@conn)}',
78
() => copied = true,
89
() => copied = false
910
)
1011
"}>
11-
Copy OPML Feed
12+
Copy OPML <span class="hidden sm:inline">Feed</span>
1213
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
13-
</.button>
14+
</.link>
1415
<.link href={~p"/sources/new"}>
1516
<.button color="bg-primary" rounding="rounded-lg">
1617
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>

lib/pinchflat_web/endpoint.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ defmodule PinchflatWeb.Endpoint do
7373
Phoenix.Controller.put_router_url(conn, new_base_url)
7474
end
7575

76+
# Some podcast clients require file extensions, and others still will _add_
77+
# file extensions to XML files if they don't have them. This plug removes
78+
# the extension from the path so that the correct route is matched, regardless
79+
# of the provided extension.
80+
#
81+
# This has the downside of in-app generated verified routes not working with
82+
# extensions so this behaviour may change in the future.
7683
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
7784

7885
defp strip_trailing_extension(conn, _opts) do

lib/pinchflat_web/plugs.ex

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
defmodule PinchflatWeb.Plugs do
2+
@moduledoc """
3+
Custom plugs for PinchflatWeb.
4+
"""
5+
6+
use PinchflatWeb, :router
7+
alias Pinchflat.Settings
8+
9+
@doc """
10+
If the `expose_feed_endpoints` setting is true, this plug does nothing. Otherwise, it calls `basic_auth/2`.
11+
"""
12+
def maybe_basic_auth(conn, opts) do
13+
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
14+
conn
15+
else
16+
basic_auth(conn, opts)
17+
end
18+
end
19+
20+
@doc """
21+
If the `basic_auth_username` and `basic_auth_password` settings are set, this plug calls `Plug.BasicAuth.basic_auth/3`.
22+
"""
23+
def basic_auth(conn, _opts) do
24+
username = Application.get_env(:pinchflat, :basic_auth_username)
25+
password = Application.get_env(:pinchflat, :basic_auth_password)
26+
27+
if credential_set?(username) && credential_set?(password) do
28+
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
29+
else
30+
conn
31+
end
32+
end
33+
34+
@doc """
35+
Removes the `x-frame-options` header from the response to allow the page to be embedded in an iframe.
36+
"""
37+
def allow_iframe_embed(conn, _opts) do
38+
delete_resp_header(conn, "x-frame-options")
39+
end
40+
41+
@doc """
42+
If the `route_token` query parameter matches the `route_token` setting, this plug does nothing.
43+
Otherwise, it sends a 401 response.
44+
"""
45+
def token_protected_route(%{query_params: %{"route_token" => route_token}} = conn, _opts) do
46+
if Settings.get!(:route_token) == route_token do
47+
conn
48+
else
49+
send_unauthorized(conn)
50+
end
51+
end
52+
53+
def token_protected_route(conn, _opts) do
54+
send_unauthorized(conn)
55+
end
56+
57+
defp credential_set?(credential) do
58+
credential && credential != ""
59+
end
60+
61+
defp send_unauthorized(conn) do
62+
conn
63+
|> send_resp(:unauthorized, "Unauthorized")
64+
|> halt()
65+
end
66+
end

lib/pinchflat_web/router.ex

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
defmodule PinchflatWeb.Router do
22
use PinchflatWeb, :router
3+
import PinchflatWeb.Plugs
34
import Phoenix.LiveDashboard.Router
45

56
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
@@ -19,16 +20,18 @@ defmodule PinchflatWeb.Router do
1920
plug :accepts, ["json"]
2021
end
2122

22-
pipeline :feeds do
23-
plug :maybe_basic_auth
23+
scope "/", PinchflatWeb do
24+
pipe_through [:maybe_basic_auth, :token_protected_route]
25+
26+
# has to match before /sources/:id
27+
get "/sources/opml", Podcasts.PodcastController, :opml_feed
28+
get "/sources/:foo/opml", Podcasts.PodcastController, :opml_feed
2429
end
2530

2631
# Routes in here _may not be_ protected by basic auth. This is necessary for
2732
# media streaming to work for RSS podcast feeds.
2833
scope "/", PinchflatWeb do
29-
pipe_through :feeds
30-
# has to match before /sources/:id
31-
get "/sources/opml", Podcasts.PodcastController, :opml_feed
34+
pipe_through :maybe_basic_auth
3235

3336
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
3437
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
@@ -76,31 +79,4 @@ defmodule PinchflatWeb.Router do
7679
metrics: PinchflatWeb.Telemetry,
7780
ecto_repos: [Pinchflat.Repo]
7881
end
79-
80-
defp maybe_basic_auth(conn, opts) do
81-
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
82-
conn
83-
else
84-
basic_auth(conn, opts)
85-
end
86-
end
87-
88-
defp basic_auth(conn, _opts) do
89-
username = Application.get_env(:pinchflat, :basic_auth_username)
90-
password = Application.get_env(:pinchflat, :basic_auth_password)
91-
92-
if credential_set?(username) && credential_set?(password) do
93-
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
94-
else
95-
conn
96-
end
97-
end
98-
99-
defp credential_set?(credential) do
100-
credential && credential != ""
101-
end
102-
103-
defp allow_iframe_embed(conn, _opts) do
104-
delete_resp_header(conn, "x-frame-options")
105-
end
10682
end

priv/repo/erd.png

3.57 KB
Loading
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
defmodule Pinchflat.Repo.Migrations.AddRouteTokenToSettings do
2+
use Ecto.Migration
3+
4+
def change do
5+
alter table(:settings) do
6+
add :route_token, :string, null: false, default: "tmp-token"
7+
end
8+
9+
execute "UPDATE settings SET route_token = gen_random_uuid();", "SELECT 1;"
10+
end
11+
end

test/pinchflat_web/controllers/podcast_controller_test.exs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,34 @@ defmodule PinchflatWeb.PodcastControllerTest do
44
import Pinchflat.MediaFixtures
55
import Pinchflat.SourcesFixtures
66

7+
alias Pinchflat.Settings
8+
79
describe "opml_feed" do
810
test "renders the XML document", %{conn: conn} do
911
source = source_fixture()
12+
route_token = Settings.get!(:route_token)
1013

11-
conn = get(conn, ~p"/sources/opml" <> ".xml")
14+
conn = get(conn, ~p"/sources/opml.xml?#{[route_token: route_token]}")
1215

1316
assert conn.status == 200
1417
assert {"content-type", "application/opml+xml; charset=utf-8"} in conn.resp_headers
1518
assert {"content-disposition", "inline"} in conn.resp_headers
1619
assert conn.resp_body =~ ~s"http://www.example.com/sources/#{source.uuid}/feed.xml"
17-
assert conn.resp_body =~ "text=\"Cool and good internal name!\""
20+
assert conn.resp_body =~ "text=\"#{source.custom_name}\""
21+
end
22+
23+
test "returns 401 if the route token is incorrect", %{conn: conn} do
24+
conn = get(conn, ~p"/sources/opml.xml?route_token=incorrect")
25+
26+
assert conn.status == 401
27+
assert conn.resp_body == "Unauthorized"
28+
end
29+
30+
test "returns 401 if the route token is missing", %{conn: conn} do
31+
conn = get(conn, ~p"/sources/opml.xml")
32+
33+
assert conn.status == 401
34+
assert conn.resp_body == "Unauthorized"
1835
end
1936
end
2037

0 commit comments

Comments
 (0)