defmodule GitGudWeb.PrivateRepoAccessTest do
@moduledoc """
A private repository must not be readable by someone who can't read
it — through any page, not just the ones anybody remembered.
This exists because it wasn't true. The router said "visibility is
enforced inside the LiveView itself" and eighteen of the nineteen
browsing LiveViews fetched the repository and rendered it, so a
private repository's file tree, blobs, commits, issues, pull
requests, wiki and CI were readable by anyone who typed the URL. The
listings hid them correctly, which is what made it survive.
So the test is deliberately written against the *route table* rather
than against a hand-picked list of pages: every repository-scoped
path is asserted, and a new page that forgets the gate fails here.
"""
use GitGudWeb.ConnCase, async: false
import GitGud.AccountsFixtures
import GitGud.ForgeFixtures
alias GitGud.Repositories
setup do
owner = user_fixture()
{:ok, private} =
Repositories.create_repository(owner, %{
"name" => unique_repo_name(),
"visibility" => "private"
})
{:ok, public} =
Repositories.create_repository(owner, %{
"name" => unique_repo_name(),
"visibility" => "public"
})
{:ok, internal} =
Repositories.create_repository(owner, %{
"name" => unique_repo_name(),
"visibility" => "internal"
})
%{owner: owner, private: private, public: public, internal: internal}
end
# Every read-only page a repository has. Paths, not LiveView modules,
# because what leaked was reachable URLs.
defp browsing_paths(handle, name) do
[
"/r/#{handle}/#{name}",
"/r/#{handle}/#{name}/tree/master/",
"/r/#{handle}/#{name}/blob/master/README.md",
"/r/#{handle}/#{name}/commits/master",
"/r/#{handle}/#{name}/issues",
"/r/#{handle}/#{name}/pulls",
"/r/#{handle}/#{name}/wiki",
"/r/#{handle}/#{name}/actions",
"/r/#{handle}/#{name}/forks",
"/r/#{handle}/#{name}/compare",
"/r/#{handle}/#{name}/packages",
"/r/#{handle}/#{name}/stats/languages",
"/r/#{handle}/#{name}/stats/committers"
]
end
describe "an anonymous visitor" do
test "gets a 404 from every page of a private repository", %{
conn: conn,
owner: owner,
private: private
} do
for path <- browsing_paths(owner.handle, private.name) do
assert_404(conn, path)
end
end
test "can still read a public repository", %{conn: conn, owner: owner, public: public} do
# The gate has to be a gate, not a wall — this is the assertion
# that would catch closing it too far.
assert conn |> get("/r/#{owner.handle}/#{public.name}") |> html_response(200)
end
end
describe "a signed-in stranger" do
test "gets a 404 from every page of a private repository", %{
conn: conn,
owner: owner,
private: private
} do
conn = log_in_user(conn, user_fixture())
# Being signed in says nothing about this repository, and a 403
# here would confirm it exists.
for path <- browsing_paths(owner.handle, private.name) do
assert_404(conn, path)
end
end
end
describe "the owner" do
test "can read their own private repository", %{conn: conn, owner: owner, private: private} do
assert conn
|> log_in_user(owner)
|> get("/r/#{owner.handle}/#{private.name}")
|> html_response(200)
end
end
describe "indistinguishability" do
test "a private repository answers exactly like one that isn't there", %{
conn: conn,
owner: owner,
private: private
} do
missing = catch_status(conn, "/r/#{owner.handle}/definitely-not-a-repo")
hidden = catch_status(conn, "/r/#{owner.handle}/#{private.name}")
# Same status, so probing the URL space tells an attacker nothing
# about which private repositories exist.
assert missing == hidden
assert hidden == 404
end
end
describe "internal repositories" do
test "are not readable by an anonymous visitor", %{
conn: conn,
owner: owner,
internal: internal
} do
# The settings page says "Internal — any signed-in user can read".
# Nobody is not a signed-in user. This used to be readable, while
# every listing correctly hid it, so an internal repository was
# invisible and world-readable at the same time.
for path <- browsing_paths(owner.handle, internal.name) do
assert_404(conn, path)
end
end
test "are readable by any signed-in user", %{conn: conn, owner: owner, internal: internal} do
# The other half of the promise: internal is not private, and
# closing it to everyone would be a different bug.
assert conn
|> log_in_user(user_fixture())
|> get("/r/#{owner.handle}/#{internal.name}")
|> html_response(200)
end
test "are not listed to an anonymous visitor on a profile", %{
conn: conn,
owner: owner,
internal: internal,
public: public
} do
html = conn |> get("/u/#{owner.handle}") |> html_response(200)
assert html =~ public.name
refute html =~ internal.name
end
end
defp assert_404(conn, path) do
assert catch_status(conn, path) == 404, "#{path} did not 404"
end
# A LiveView that refuses to mount raises; Phoenix maps
# Ecto.NoResultsError to a 404. Either shape counts as "not served".
defp catch_status(conn, path) do
try do
get(conn, path).status
rescue
Ecto.NoResultsError -> 404
Phoenix.Router.NoRouteError -> 404
catch
:exit, {{%Ecto.NoResultsError{}, _}, _} -> 404
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
5.7 KiB · text
History
b868907