Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
85
test/tarakan_web/plugs/api_rate_limit_test.exs
Normal file
85
test/tarakan_web/plugs/api_rate_limit_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
defmodule TarakanWeb.Plugs.ApiRateLimitTest do
|
||||
use Tarakan.DataCase, async: true
|
||||
|
||||
import Plug.Conn
|
||||
import Plug.Test
|
||||
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias TarakanWeb.Plugs.ApiRateLimit
|
||||
|
||||
defp call_ip(conn, opts) do
|
||||
ApiRateLimit.call(conn, ApiRateLimit.init([mode: :ip] ++ opts))
|
||||
end
|
||||
|
||||
defp call_actor(conn, scope, opts) do
|
||||
conn
|
||||
|> assign(:current_scope, scope)
|
||||
|> ApiRateLimit.call(ApiRateLimit.init([mode: :actor] ++ opts))
|
||||
end
|
||||
|
||||
defp credential_scope(account, token_id) do
|
||||
Scope.for_account(account, token_id: token_id, authentication_method: :api_credential)
|
||||
end
|
||||
|
||||
test "requests carrying a Bearer token count against the IP bucket" do
|
||||
opts = [request_limit: 2, mutation_limit: 2, window_seconds: 60]
|
||||
|
||||
for _ <- 1..2 do
|
||||
conn =
|
||||
conn(:get, "/api/repositories")
|
||||
|> put_req_header("authorization", "Bearer garbage-token")
|
||||
|> call_ip(opts)
|
||||
|
||||
refute conn.halted
|
||||
end
|
||||
|
||||
conn =
|
||||
conn(:get, "/api/repositories")
|
||||
|> put_req_header("authorization", "Bearer garbage-token")
|
||||
|> call_ip(opts)
|
||||
|
||||
assert conn.halted
|
||||
assert conn.status == 429
|
||||
assert Jason.decode!(conn.resp_body)["error"] == "rate limit exceeded"
|
||||
end
|
||||
|
||||
test "IPv6 clients in the same /64 share one IP bucket" do
|
||||
opts = [request_limit: 1, mutation_limit: 1, window_seconds: 60]
|
||||
|
||||
first =
|
||||
conn(:get, "/api/repositories")
|
||||
|> Map.put(:remote_ip, {0x2001, 0x0DB8, 0, 0, 0, 0, 0, 1})
|
||||
|> call_ip(opts)
|
||||
|
||||
refute first.halted
|
||||
|
||||
second =
|
||||
conn(:get, "/api/repositories")
|
||||
|> Map.put(:remote_ip, {0x2001, 0x0DB8, 0, 0, 0xAAAA, 0xBBBB, 0xCCCC, 0xDDDD})
|
||||
|> call_ip(opts)
|
||||
|
||||
assert second.halted
|
||||
assert second.status == 429
|
||||
end
|
||||
|
||||
test "plain requests still count against the IP bucket" do
|
||||
opts = [request_limit: 1, mutation_limit: 1, window_seconds: 60]
|
||||
|
||||
refute conn(:get, "/api/repositories") |> call_ip(opts) |> Map.fetch!(:halted)
|
||||
assert conn(:get, "/api/repositories") |> call_ip(opts) |> Map.fetch!(:halted)
|
||||
end
|
||||
|
||||
test "authenticated actors get the multiplied actor budget" do
|
||||
opts = [request_limit: 2, mutation_limit: 2, window_seconds: 60]
|
||||
scope = credential_scope(account_fixture(), 11_001)
|
||||
|
||||
# 2 × @actor_multiplier (4) = 8 requests fit; the 9th is rejected.
|
||||
for _ <- 1..8 do
|
||||
refute conn(:get, "/api/jobs") |> call_actor(scope, opts) |> Map.fetch!(:halted)
|
||||
end
|
||||
|
||||
limited = conn(:get, "/api/jobs") |> call_actor(scope, opts)
|
||||
assert limited.halted
|
||||
assert limited.status == 429
|
||||
end
|
||||
end
|
||||
114
test/tarakan_web/plugs/client_ip_test.exs
Normal file
114
test/tarakan_web/plugs/client_ip_test.exs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
defmodule TarakanWeb.Plugs.ClientIpTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Plug.Conn
|
||||
import Plug.Test
|
||||
|
||||
alias Tarakan.TrustedProxies
|
||||
alias TarakanWeb.Plugs.ClientIp
|
||||
|
||||
setup do
|
||||
previous = Application.get_env(:tarakan, :trusted_proxies, [])
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:tarakan, :trusted_proxies, previous)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "does not trust X-Forwarded-For without configured proxies" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, [])
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {10, 0, 0, 5})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {10, 0, 0, 5}
|
||||
end
|
||||
|
||||
test "rewrites remote_ip when the peer is a trusted proxy" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {10, 0, 0, 5})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9, 10.0.0.5")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {203, 0, 113, 9}
|
||||
end
|
||||
|
||||
test "ignores forwarded headers from untrusted peers" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {198, 51, 100, 1})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {198, 51, 100, 1}
|
||||
end
|
||||
|
||||
test "picks the rightmost untrusted hop, not a client-spoofed leftmost entry" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
# Attacker sends a forged leftmost value; the trusted proxy appends the real
|
||||
# peer it observed. remote_ip must be the real hop, never the forged one.
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {10, 0, 0, 5})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9, 198.51.100.7")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {198, 51, 100, 7}
|
||||
end
|
||||
|
||||
test "strips multiple trailing trusted proxies down to the client" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {10, 0, 0, 9})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9, 10.0.0.5, 10.0.0.9")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {203, 0, 113, 9}
|
||||
end
|
||||
|
||||
test "matches an IPv4 trusted proxy against an IPv6-mapped IPv4 peer" do
|
||||
# On a dual-stack (`::`) listener, an IPv4 proxy arrives as ::ffff:10.0.0.5.
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {0, 0, 0, 0, 0, 0xFFFF, 0x0A00, 0x0005})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {203, 0, 113, 9}
|
||||
end
|
||||
|
||||
test "normalizes an IPv6-mapped remote_ip to plain IPv4 for rate-limit keys" do
|
||||
conn = %Plug.Conn{remote_ip: {0, 0, 0, 0, 0, 0xFFFF, 0xCB00, 0x7109}}
|
||||
assert ClientIp.remote_ip_string(conn) == "203.0.113.9"
|
||||
end
|
||||
|
||||
test "remote_ip_bucket keeps the full IPv4 address" do
|
||||
conn = %Plug.Conn{remote_ip: {203, 0, 113, 9}}
|
||||
assert ClientIp.remote_ip_bucket(conn) == {:v4, 203, 0, 113, 9}
|
||||
end
|
||||
|
||||
test "remote_ip_bucket truncates IPv6 to the /64 prefix" do
|
||||
conn = %Plug.Conn{remote_ip: {0x2001, 0x0DB8, 0x0001, 0x0002, 1, 2, 3, 4}}
|
||||
assert ClientIp.remote_ip_bucket(conn) == {:v6, 0x2001, 0x0DB8, 0x0001, 0x0002}
|
||||
end
|
||||
|
||||
test "remote_ip_bucket folds IPv4-mapped IPv6 into the IPv4 bucket" do
|
||||
conn = %Plug.Conn{remote_ip: {0, 0, 0, 0, 0, 0xFFFF, 0xCB00, 0x7109}}
|
||||
assert ClientIp.remote_ip_bucket(conn) == {:v4, 203, 0, 113, 9}
|
||||
end
|
||||
end
|
||||
68
test/tarakan_web/plugs/code_browser_rate_limit_test.exs
Normal file
68
test/tarakan_web/plugs/code_browser_rate_limit_test.exs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
defmodule TarakanWeb.Plugs.CodeBrowserRateLimitTest do
|
||||
use Tarakan.DataCase, async: true
|
||||
|
||||
import Plug.Conn
|
||||
import Plug.Test
|
||||
|
||||
alias TarakanWeb.Plugs.CodeBrowserRateLimit
|
||||
|
||||
test "allows requests under the per-IP budget" do
|
||||
opts = CodeBrowserRateLimit.init(limit: 2, window_seconds: 60)
|
||||
|
||||
for path <- [
|
||||
"/findings/55d5c681-240a-4e3a-a1f8-45933a30c4ef/code",
|
||||
"/github.com/openai/codex"
|
||||
] do
|
||||
conn = conn(:get, path) |> CodeBrowserRateLimit.call(opts)
|
||||
refute conn.halted
|
||||
end
|
||||
end
|
||||
|
||||
test "answers 429 once the per-IP budget is exceeded" do
|
||||
opts = CodeBrowserRateLimit.init(limit: 2, window_seconds: 60)
|
||||
|
||||
for _ <- 1..2 do
|
||||
conn = conn(:get, "/github.com/openai/codex") |> CodeBrowserRateLimit.call(opts)
|
||||
refute conn.halted
|
||||
end
|
||||
|
||||
conn = conn(:get, "/github.com/openai/codex") |> CodeBrowserRateLimit.call(opts)
|
||||
|
||||
assert conn.halted
|
||||
assert conn.status == 429
|
||||
assert [retry_after] = get_resp_header(conn, "retry-after")
|
||||
assert String.to_integer(retry_after) > 0
|
||||
end
|
||||
|
||||
test "IPv6 clients in the same /64 share one bucket" do
|
||||
opts = CodeBrowserRateLimit.init(limit: 1, window_seconds: 60)
|
||||
|
||||
first =
|
||||
conn(:get, "/github.com/openai/codex")
|
||||
|> Map.put(:remote_ip, {0x2001, 0x0DB8, 0, 0, 0, 0, 0, 1})
|
||||
|> CodeBrowserRateLimit.call(opts)
|
||||
|
||||
refute first.halted
|
||||
|
||||
second =
|
||||
conn(:get, "/github.com/openai/codex")
|
||||
|> Map.put(:remote_ip, {0x2001, 0x0DB8, 0, 0, 0xFFFF, 0xEEEE, 0xDDDD, 0xCCCC})
|
||||
|> CodeBrowserRateLimit.call(opts)
|
||||
|
||||
assert second.halted
|
||||
assert second.status == 429
|
||||
end
|
||||
|
||||
test "admins and moderators are exempt from the budget" do
|
||||
opts = CodeBrowserRateLimit.init(limit: 1, window_seconds: 60)
|
||||
|
||||
for role <- ["admin", "moderator"], _ <- 1..3 do
|
||||
conn =
|
||||
conn(:get, "/github.com/openai/codex")
|
||||
|> assign(:current_scope, %{platform_role: role})
|
||||
|> CodeBrowserRateLimit.call(opts)
|
||||
|
||||
refute conn.halted
|
||||
end
|
||||
end
|
||||
end
|
||||
75
test/tarakan_web/plugs/forwarded_proto_test.exs
Normal file
75
test/tarakan_web/plugs/forwarded_proto_test.exs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
defmodule TarakanWeb.Plugs.ForwardedProtoTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Plug.Conn
|
||||
import Plug.Test
|
||||
|
||||
alias Tarakan.TrustedProxies
|
||||
alias TarakanWeb.Plugs.ForwardedProto
|
||||
|
||||
setup do
|
||||
previous = Application.get_env(:tarakan, :trusted_proxies, [])
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:tarakan, :trusted_proxies, previous)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "rewrites the scheme when the peer is a trusted proxy" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("127.0.0.1,::1"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {127, 0, 0, 1})
|
||||
|> put_req_header("x-forwarded-proto", "https")
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :https
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {0, 0, 0, 0, 0, 0, 0, 1})
|
||||
|> Map.put(:scheme, :https)
|
||||
|> put_req_header("x-forwarded-proto", "http")
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :http
|
||||
end
|
||||
|
||||
test "ignores X-Forwarded-Proto from untrusted peers" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {198, 51, 100, 1})
|
||||
|> put_req_header("x-forwarded-proto", "https")
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :http
|
||||
end
|
||||
|
||||
test "ignores X-Forwarded-Proto without configured proxies" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, [])
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {127, 0, 0, 1})
|
||||
|> put_req_header("x-forwarded-proto", "https")
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :http
|
||||
end
|
||||
|
||||
test "leaves the scheme untouched without the header" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("127.0.0.1"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {127, 0, 0, 1})
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :http
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue