Skip to content

Commit

Permalink
[Bugfix] Improve OPML route security (#535)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kieraneglin authored Dec 31, 2024
1 parent 246ca3b commit f51b219
Show file tree
Hide file tree
Showing 12 changed files with 295 additions and 158 deletions.
1 change: 1 addition & 0 deletions lib/pinchflat/settings/setting.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ defmodule Pinchflat.Settings.Setting do
field :apprise_version, :string
field :apprise_server, :string
field :youtube_api_key, :string
field :route_token, :string

field :video_codec_preference, :string
field :audio_codec_preference, :string
Expand Down
4 changes: 3 additions & 1 deletion lib/pinchflat_web/controllers/sources/source_html.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ defmodule PinchflatWeb.Sources.SourceHTML do
end

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

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

def output_path_template_override_placeholders(media_profiles) do
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<span
x-data="{ copied: false }"
x-on:click={"
copyWithCallbacks(
'#{rss_feed_url(@conn, @source)}',
() => copied = true,
() => copied = false
)
"}
>
<.link href={rss_feed_url(@conn, @source)} x-data="{ copied: false }" x-on:click={~s"
$event.preventDefault();
copyWithCallbacks(
'#{rss_feed_url(@conn, @source)}',
() => copied = true,
() => copied = false
)
"}>
Copy RSS Feed
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span>
</.link>
</:option>
<:option>
<span x-data="{ copied: false }" x-on:click={~s"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
<nav>
<.button color="bg-transparent" x-data="{ copied: false }" x-on:click={~s"
<nav class="flex items-center justify-between gap-6">
<.link href={opml_feed_url(@conn)} x-data="{ copied: false }" x-on:click={~s"
$event.preventDefault();
copyWithCallbacks(
'#{opml_feed_url(@conn)}',
() => copied = true,
() => copied = false
)
"}>
Copy OPML Feed
Copy OPML <span class="hidden sm:inline">Feed</span>
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</.button>
</.link>
<.link href={~p"/sources/new"}>
<.button color="bg-primary" rounding="rounded-lg">
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
Expand Down
7 changes: 7 additions & 0 deletions lib/pinchflat_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ defmodule PinchflatWeb.Endpoint do
Phoenix.Controller.put_router_url(conn, new_base_url)
end

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

defp strip_trailing_extension(conn, _opts) do
Expand Down
66 changes: 66 additions & 0 deletions lib/pinchflat_web/plugs.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
defmodule PinchflatWeb.Plugs do
@moduledoc """
Custom plugs for PinchflatWeb.
"""

use PinchflatWeb, :router
alias Pinchflat.Settings

@doc """
If the `expose_feed_endpoints` setting is true, this plug does nothing. Otherwise, it calls `basic_auth/2`.
"""
def maybe_basic_auth(conn, opts) do
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
conn
else
basic_auth(conn, opts)
end
end

@doc """
If the `basic_auth_username` and `basic_auth_password` settings are set, this plug calls `Plug.BasicAuth.basic_auth/3`.
"""
def basic_auth(conn, _opts) do
username = Application.get_env(:pinchflat, :basic_auth_username)
password = Application.get_env(:pinchflat, :basic_auth_password)

if credential_set?(username) && credential_set?(password) do
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
else
conn
end
end

@doc """
Removes the `x-frame-options` header from the response to allow the page to be embedded in an iframe.
"""
def allow_iframe_embed(conn, _opts) do
delete_resp_header(conn, "x-frame-options")
end

@doc """
If the `route_token` query parameter matches the `route_token` setting, this plug does nothing.
Otherwise, it sends a 401 response.
"""
def token_protected_route(%{query_params: %{"route_token" => route_token}} = conn, _opts) do
if Settings.get!(:route_token) == route_token do
conn
else
send_unauthorized(conn)
end
end

def token_protected_route(conn, _opts) do
send_unauthorized(conn)
end

defp credential_set?(credential) do
credential && credential != ""
end

defp send_unauthorized(conn) do
conn
|> send_resp(:unauthorized, "Unauthorized")
|> halt()
end
end
40 changes: 8 additions & 32 deletions lib/pinchflat_web/router.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defmodule PinchflatWeb.Router do
use PinchflatWeb, :router
import PinchflatWeb.Plugs
import Phoenix.LiveDashboard.Router

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

pipeline :feeds do
plug :maybe_basic_auth
scope "/", PinchflatWeb do
pipe_through [:maybe_basic_auth, :token_protected_route]

# has to match before /sources/:id
get "/sources/opml", Podcasts.PodcastController, :opml_feed
get "/sources/:foo/opml", Podcasts.PodcastController, :opml_feed
end

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

get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
Expand Down Expand Up @@ -76,31 +79,4 @@ defmodule PinchflatWeb.Router do
metrics: PinchflatWeb.Telemetry,
ecto_repos: [Pinchflat.Repo]
end

defp maybe_basic_auth(conn, opts) do
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
conn
else
basic_auth(conn, opts)
end
end

defp basic_auth(conn, _opts) do
username = Application.get_env(:pinchflat, :basic_auth_username)
password = Application.get_env(:pinchflat, :basic_auth_password)

if credential_set?(username) && credential_set?(password) do
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
else
conn
end
end

defp credential_set?(credential) do
credential && credential != ""
end

defp allow_iframe_embed(conn, _opts) do
delete_resp_header(conn, "x-frame-options")
end
end
Binary file modified priv/repo/erd.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Pinchflat.Repo.Migrations.AddRouteTokenToSettings do
use Ecto.Migration

def change do
alter table(:settings) do
add :route_token, :string, null: false, default: "tmp-token"
end

execute "UPDATE settings SET route_token = gen_random_uuid();", "SELECT 1;"
end
end
21 changes: 19 additions & 2 deletions test/pinchflat_web/controllers/podcast_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,34 @@ defmodule PinchflatWeb.PodcastControllerTest do
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures

alias Pinchflat.Settings

describe "opml_feed" do
test "renders the XML document", %{conn: conn} do
source = source_fixture()
route_token = Settings.get!(:route_token)

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

assert conn.status == 200
assert {"content-type", "application/opml+xml; charset=utf-8"} in conn.resp_headers
assert {"content-disposition", "inline"} in conn.resp_headers
assert conn.resp_body =~ ~s"http://www.example.com/sources/#{source.uuid}/feed.xml"
assert conn.resp_body =~ "text=\"Cool and good internal name!\""
assert conn.resp_body =~ "text=\"#{source.custom_name}\""
end

test "returns 401 if the route token is incorrect", %{conn: conn} do
conn = get(conn, ~p"/sources/opml.xml?route_token=incorrect")

assert conn.status == 401
assert conn.resp_body == "Unauthorized"
end

test "returns 401 if the route token is missing", %{conn: conn} do
conn = get(conn, ~p"/sources/opml.xml")

assert conn.status == 401
assert conn.resp_body == "Unauthorized"
end
end

Expand Down
Loading

0 comments on commit f51b219

Please sign in to comment.