Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

security: harden garden socket auth

- Move auth token from URL query param to x-auth-token header (sow-147)
Server accepts both header and query param for backward compat.
Garden client now sends via header only.
- Remove longpoll transport from garden socket (sow-144)
- Use Base.decode64 instead of decode64! for untrusted tokens (sow-145)
- Use exact scope matching instead of String.contains? (sow-146)

sow-144, sow-145, sow-146, sow-147

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+116 -35
+8 -6
apps/garden/lib/garden/socket.ex
··· 134 134 135 135 defp build_connect_config do 136 136 config = Application.get_all_env(__MODULE__) 137 - token_param = resolve_connect_token() 137 + token = resolve_connect_token() 138 138 uri = Keyword.get(config, :uri) 139 139 140 140 Logger.info(msg: "Connecting to server socket", endpoint: uri) 141 141 142 - uri = 143 - uri 144 - |> Map.put(:query, "token=#{token_param}") 145 - |> URI.to_string() 142 + auth_header = {"x-auth-token", token} 143 + uri = URI.to_string(uri) 146 144 147 - Keyword.put(config, :uri, uri) 145 + config 146 + |> Keyword.put(:uri, uri) 147 + |> Keyword.update(:headers, [auth_header], fn headers -> 148 + [auth_header | headers] 149 + end) 148 150 end 149 151 150 152 defp resolve_connect_token do
+1 -1
apps/sower/lib/sower/authorization/permissions.ex
··· 18 18 org_id: org_id, 19 19 scope: scope 20 20 }) do 21 - if String.contains?(scope, "garden:agent") do 21 + if "garden:agent" in String.split(scope) do 22 22 permit 23 23 |> read(Sower.Orchestration.Garden, org_id: org_id) 24 24 else
+7 -2
apps/sower/lib/sower_web/endpoint.ex
··· 18 18 19 19 socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 20 20 21 - socket "/garden", SowerWeb.GardenSocket, websocket: true, longpoll: true 21 + socket "/garden", SowerWeb.GardenSocket, 22 + websocket: [connect_info: [:x_headers]], 23 + longpoll: false 24 + 22 25 # Deprecated: kept for 0.7.0 garden backward compatibility 23 - socket "/agent", SowerWeb.GardenSocket, websocket: true, longpoll: true 26 + socket "/agent", SowerWeb.GardenSocket, 27 + websocket: [connect_info: [:x_headers]], 28 + longpoll: false 24 29 25 30 # Serve at "/" the static files from "priv/static" directory. 26 31 #
+44 -26
apps/sower/lib/sower_web/garden_socket.ex
··· 7 7 channel("agent:*", SowerWeb.GardenChannel) 8 8 9 9 @impl Phoenix.Socket 10 - def connect(%{"token" => token}, socket, _connect_info) do 11 - case authenticate_token(token) do 12 - {:ok, access_token} -> 13 - socket = 14 - socket 15 - |> assign(:access_token, access_token) 16 - |> assign(:conn_sid, SowerClient.Sid.generate("conn")) 10 + def connect(params, socket, connect_info) do 11 + case extract_token(params, connect_info) do 12 + {:ok, token} -> 13 + case authenticate_token(token) do 14 + {:ok, access_token} -> 15 + socket = 16 + socket 17 + |> assign(:access_token, access_token) 18 + |> assign(:conn_sid, SowerClient.Sid.generate("conn")) 19 + 20 + {:ok, socket} 17 21 18 - {:ok, socket} 22 + {:error, error} -> 23 + Logger.error(msg: "Authentication failed", error: error) 24 + {:error, :unauthorized} 25 + end 19 26 20 - {:error, error} -> 21 - Logger.error(msg: "Authentication failed", error: error) 27 + :error -> 28 + Logger.error(msg: "unauthorized connection") 22 29 {:error, :unauthorized} 23 30 end 24 31 end 25 32 26 - def connect(_, _socket, _connect_info) do 27 - Logger.error(msg: "unauthorized connection") 28 - {:error, :unauthorized} 29 - end 30 - 31 33 @impl Phoenix.Socket 32 34 def id(_socket), do: nil 33 35 36 + defp extract_token(params, %{x_headers: headers}) do 37 + case List.keyfind(headers, "x-auth-token", 0) do 38 + {_, token} -> {:ok, token} 39 + nil -> extract_token_from_params(params) 40 + end 41 + end 42 + 43 + defp extract_token(params, _connect_info), do: extract_token_from_params(params) 44 + 45 + # Fallback for query param (pre-0.8.0 garden compat) 46 + defp extract_token_from_params(%{"token" => token}), do: {:ok, token} 47 + defp extract_token_from_params(_params), do: :error 48 + 34 49 defp authenticate_token("boruta:" <> boruta_token) do 35 50 case Boruta.Oauth.Authorization.AccessToken.authorize(value: boruta_token) do 36 51 {:ok, oauth_token} -> ··· 58 73 end 59 74 60 75 defp authenticate_token(base64_token) do 61 - case base64_token |> Base.decode64!() |> Sower.Accounts.AccessToken.authenticate() do 62 - {:ok, access_token} -> 63 - if access_token |> can() |> read?(Sower.Orchestration.Garden) do 64 - {:ok, access_token} 65 - else 66 - Logger.error( 67 - msg: "Access token is not authorized to be a garden", 68 - access_token_sid: access_token.sid 69 - ) 76 + with {:ok, decoded} <- Base.decode64(base64_token), 77 + {:ok, access_token} <- Sower.Accounts.AccessToken.authenticate(decoded) do 78 + if access_token |> can() |> read?(Sower.Orchestration.Garden) do 79 + {:ok, access_token} 80 + else 81 + Logger.error( 82 + msg: "Access token is not authorized to be a garden", 83 + access_token_sid: access_token.sid 84 + ) 70 85 71 - {:error, :unauthorized} 72 - end 86 + {:error, :unauthorized} 87 + end 88 + else 89 + :error -> 90 + {:error, :invalid_token} 73 91 74 92 {:error, error} -> 75 93 {:error, error}
+56
apps/sower/test/sower_web/channels/garden_channel_test.exs
··· 1 1 defmodule SowerWeb.GardenChannelTest do 2 2 use SowerWeb.ChannelCase, async: true 3 3 4 + import ExUnit.CaptureLog 5 + 6 + describe "connect/3" do 7 + test "authenticates via x-auth-token header" do 8 + user = user_fixture() 9 + Sower.Repo.put_org_id(user.org_id) 10 + 11 + {:ok, access_token} = 12 + Sower.Accounts.AccessToken.create(%{ 13 + "description" => "test", 14 + "user_id" => user.id, 15 + "org_id" => user.org_id, 16 + "permissions" => [%{"role" => "garden:register"}] 17 + }) 18 + 19 + encoded_token = Base.encode64(access_token.token) 20 + 21 + {:ok, _socket} = 22 + connect(SowerWeb.GardenSocket, %{}, 23 + connect_info: %{x_headers: [{"x-auth-token", encoded_token}]} 24 + ) 25 + end 26 + 27 + test "falls back to query param token for backward compat" do 28 + user = user_fixture() 29 + Sower.Repo.put_org_id(user.org_id) 30 + 31 + {:ok, access_token} = 32 + Sower.Accounts.AccessToken.create(%{ 33 + "description" => "test", 34 + "user_id" => user.id, 35 + "org_id" => user.org_id, 36 + "permissions" => [%{"role" => "garden:register"}] 37 + }) 38 + 39 + encoded_token = Base.encode64(access_token.token) 40 + 41 + {:ok, _socket} = 42 + connect(SowerWeb.GardenSocket, %{"token" => encoded_token}) 43 + end 44 + 45 + test "rejects invalid base64 token" do 46 + capture_log(fn -> 47 + assert {:error, :unauthorized} = 48 + connect(SowerWeb.GardenSocket, %{"token" => "not-valid-base64!!!"}) 49 + end) 50 + end 51 + 52 + test "rejects connection with no token" do 53 + capture_log(fn -> 54 + assert {:error, :unauthorized} = 55 + connect(SowerWeb.GardenSocket, %{}) 56 + end) 57 + end 58 + end 59 + 4 60 describe "join/3" do 5 61 test "joins with matching local_sid and assigns garden" do 6 62 %{socket: socket, garden: garden} = connect_and_join_garden()