Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions lib/console/ai/graph/provider/elastic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ defmodule Console.AI.Graph.Provider.Elastic do

def init(%__MODULE__{conn: %Elastic{index: index} = es}) do
Elastic.url(es, curr_index(index))
|> HTTPoison.put(Jason.encode!(@index_mappings), Elastic.headers(es, @headers))
|> Req.put(headers: Elastic.headers(es, @headers), body: Jason.encode!(@index_mappings), decode_body: false, retry: false)
|> handle_response("could not initialize elasticsearch:")
end

Expand All @@ -56,7 +56,7 @@ defmodule Console.AI.Graph.Provider.Elastic do
|> Enum.join("\n")

Elastic.url(es, "/_bulk")
|> HTTPoison.post("#{bulk}\n", Elastic.headers(es, [{"Content-Type", "application/x-ndjson"}]))
|> Req.post(headers: Elastic.headers(es, [{"Content-Type", "application/x-ndjson"}]), body: "#{bulk}\n", decode_body: false, retry: false)
|> handle_response("could not bulk index into elasticsearch:")
end

Expand Down Expand Up @@ -133,8 +133,8 @@ defmodule Console.AI.Graph.Provider.Elastic do
defp groups(%User{group_members: [_ | _] = members}), do: [%{terms: %{group_ids: Enum.map(members, & &1.group_id)}}]
defp groups(_), do: []

defp handle_response({:ok, %HTTPoison.Response{status_code: code}}, _) when code >= 200 and code < 300, do: :ok
defp handle_response({:ok, %HTTPoison.Response{body: body}}, modifier), do: {:error, "#{modifier}: #{body}"}
defp handle_response({:ok, %Req.Response{status: code}}, _) when code >= 200 and code < 300, do: :ok
defp handle_response({:ok, %Req.Response{body: body}}, modifier), do: {:error, "#{modifier}: #{body}"}
defp handle_response(_, modifier), do: {:error, "#{modifier}: elasticsearch error"}

def curr_index(index) when is_binary(index) do
Expand Down
8 changes: 4 additions & 4 deletions lib/console/ai/provider/ollama.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule Console.AI.Ollama do

@base_headers [{"content-type", "application/json"}]

@options [recv_timeout: :timer.minutes(5), timeout: :timer.minutes(5)]
@options [receive_timeout: :timer.minutes(5), connect_options: [timeout: :timer.minutes(5)], decode_body: false, retry: false]

defmodule Message do
@type t :: %__MODULE__{}
Expand Down Expand Up @@ -77,13 +77,13 @@ defmodule Console.AI.Ollama do
})

"#{url}/api/chat"
|> HTTPoison.post(body, auth(ollama, @base_headers), @options)
|> Req.post([headers: auth(ollama, @base_headers), body: body] ++ @options)
|> handle_response(ChatResponse.spec())
end

defp handle_response({:ok, %HTTPoison.Response{status_code: code, body: body}}, type) when code in 200..299,
defp handle_response({:ok, %Req.Response{status: code, body: body}}, type) when code in 200..299,
do: Poison.decode(body, as: type)
defp handle_response({:ok, %HTTPoison.Response{body: body}}, _) do
defp handle_response({:ok, %Req.Response{body: body}}, _) do
Logger.error "ollama error: #{body}"
{:error, "ollama error: #{body}"}
end
Expand Down
15 changes: 12 additions & 3 deletions lib/console/ai/tools/workbench/http.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,25 @@ defmodule Console.AI.Tools.Workbench.Http do

def invoke(%WorkbenchTool{configuration: %Configuration{http: http}}, %{} = input) do
with {:body, {:ok, body}} <- {:body, body(http, input)},
{:request, {:ok, %HTTPoison.Response{body: body, status_code: code}}} <- {:request, do_request(http, body)} do
{:request, {:ok, %Req.Response{body: body, status: code}}} <- {:request, do_request(http, body)} do
{:ok, "http response: #{body} (status #{code})"}
else
{:body, {:error, error}} -> {:error, "could not render request body: #{inspect(error)}"}
{:request, {:error, %HTTPoison.Error{reason: reason}}} -> {:error, "HTTP error: #{inspect(reason)}"}
{:request, {:error, reason}} -> {:error, "HTTP error: #{inspect(reason)}"}
end
end

defp do_request(%HttpConfiguration{method: method, url: url} = config, body) do
HTTPoison.request(method, url, body, headers(config), [timeout: 10_000, recv_timeout: 10_000])
Req.request(
method: method,
url: url,
body: body,
headers: headers(config),
connect_options: [timeout: 10_000],
receive_timeout: 10_000,
decode_body: false,
retry: false
)
Comment on lines +51 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Outbound HTTP calls now follow redirects by default, enabling redirect-based SSRF on user-supplied URLs

The migration replaces HTTPoison (which does not follow redirects unless follow_redirect: true) with Req, whose :redirect option defaults to true. Several of the converted call sites take fully user/tenant-controlled URLs, e.g. the workbench HTTP tool (Req.request(... url: url ...) at lib/console/ai/tools/workbench/http.ex:51-60), notification sink webhooks (lib/console/deployments/notifications.ex:174-182) and the PR governance webhook (lib/console/deployments/pr/governance/impl/webhook.ex:33). An attacker who can configure such a URL can now point it at a server that responds with a 30x to an internal address (e.g. cloud metadata or in-cluster services), and the console will transparently follow it, whereas previously the redirect was surfaced as a non-2xx response and never followed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

end

defp headers(%HttpConfiguration{headers: [_ | _] = headers}), do: Enum.map(headers, &{&1.name, &1.value})
Expand Down
74 changes: 14 additions & 60 deletions lib/console/ai/tools/workbench/integration/azure_devops/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -115,33 +115,17 @@ defmodule Console.AI.Tools.Workbench.Integration.AzureDevops.Client do
def get_json(%{token: _} = client, url, query \\ %{}) when is_binary(url) do
req_url = url <> Query.query_string(query)

case HTTPoison.get(req_url, basic_auth_header(client), http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Azure DevOps API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Azure DevOps", reason)
end
Req.get(req_url, [headers: basic_auth_header(client)] ++ http_opts())
|> Http.handle("Azure DevOps")
end

@spec post_json(map(), String.t(), map()) :: {:ok, term()} | {:error, String.t()}
def post_json(%{token: _} = client, url, body_map) when is_binary(url) and is_map(body_map) do
encoded = Jason.encode!(body_map)
headers = json_auth_headers(client)

case HTTPoison.post(url, encoded, headers, http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Azure DevOps API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Azure DevOps", reason)
end
Req.post(url, [headers: headers, body: encoded] ++ http_opts())
|> Http.handle("Azure DevOps")
end

@spec put_json(map(), String.t(), map()) :: {:ok, term()} | {:error, String.t()}
Expand All @@ -153,59 +137,29 @@ defmodule Console.AI.Tools.Workbench.Integration.AzureDevops.Client do
{Jason.encode!(body_map), json_auth_headers(client)}
end

case HTTPoison.put(url, encoded, headers, http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Azure DevOps API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Azure DevOps", reason)
end
Req.put(url, [headers: headers, body: encoded] ++ http_opts())
|> Http.handle("Azure DevOps")
end

@spec patch_json(map(), String.t(), map()) :: {:ok, term()} | {:error, String.t()}
def patch_json(%{token: _} = client, url, body_map) when is_binary(url) and is_map(body_map) do
encoded = Jason.encode!(body_map)
headers = json_auth_headers(client)

case HTTPoison.patch(url, encoded, headers, http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Azure DevOps API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Azure DevOps", reason)
end
Req.patch(url, [headers: headers, body: encoded] ++ http_opts())
|> Http.handle("Azure DevOps")
end

@spec post_empty(map(), String.t()) :: {:ok, term()} | {:error, String.t()}
def post_empty(%{token: _} = client, url) when is_binary(url) do
case HTTPoison.post(url, "", basic_auth_header(client), http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Azure DevOps API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Azure DevOps", reason)
end
end

defp decode_json(""), do: {:ok, %{}}

defp decode_json(body) do
case Jason.decode(body) do
{:ok, data} -> {:ok, data}
{:error, _} -> {:error, "Azure DevOps returned non-JSON body: #{inspect(body)}"}
end
Req.post(url, [headers: basic_auth_header(client), body: ""] ++ http_opts())
|> Http.handle("Azure DevOps")
end

defp http_opts,
do:
Application.get_env(:console, :httpoison_azure_devops_options, []) ++ [recv_timeout: 60_000]
Console.Utils.HTTP.client_options(
:httpoison_azure_devops_options,
:req_azure_devops_options
)
end
35 changes: 5 additions & 30 deletions lib/console/ai/tools/workbench/integration/bitbucket/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,8 @@ defmodule Console.AI.Tools.Workbench.Integration.Bitbucket.Client do
def get(%{base_url: base, token: token}, path, query \\ %{}) when is_binary(path) do
url = base <> path <> Query.query_string(query)

case HTTPoison.get(url, auth_headers(token), http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Bitbucket Cloud API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Bitbucket Cloud", reason)
end
Req.get(url, [headers: auth_headers(token)] ++ http_opts())
|> Http.handle("Bitbucket Cloud")
end

@spec post_json(map(), String.t(), map()) :: {:ok, term()} | {:error, String.t()}
Expand All @@ -68,16 +60,8 @@ defmodule Console.AI.Tools.Workbench.Integration.Bitbucket.Client do
url = base <> path
headers = auth_headers(token) ++ [{"Content-Type", "application/json"}]

case HTTPoison.post(url, Jason.encode!(body_map), headers, http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Bitbucket Cloud API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Bitbucket Cloud", reason)
end
Req.post(url, [headers: headers, body: Jason.encode!(body_map)] ++ http_opts())
|> Http.handle("Bitbucket Cloud")
end

@spec repo_path(String.t(), String.t()) :: String.t()
Expand All @@ -91,17 +75,8 @@ defmodule Console.AI.Tools.Workbench.Integration.Bitbucket.Client do
]
end

defp decode_json(""), do: {:ok, %{}}

defp decode_json(body) do
case Jason.decode(body) do
{:ok, data} -> {:ok, data}
{:error, _} -> {:error, "Bitbucket Cloud returned non-JSON body: #{inspect(body)}"}
end
end

defp enc(s) when is_binary(s), do: URI.encode(String.trim(s), &URI.char_unreserved?/1)

defp http_opts,
do: Application.get_env(:console, :httpoison_bitbucket_options, []) ++ [recv_timeout: 60_000]
do: Console.Utils.HTTP.client_options(:httpoison_bitbucket_options, :req_bitbucket_options)
end
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,8 @@ defmodule Console.AI.Tools.Workbench.Integration.BitbucketDatacenter.Client do
def get(%{api_base: base, token: token}, path, query \\ %{}) when is_binary(path) do
url = base <> path <> Query.query_string(query)

case HTTPoison.get(url, auth_headers(token), http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Bitbucket Data Center API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Bitbucket Data Center", reason)
end
Req.get(url, [headers: auth_headers(token)] ++ http_opts())
|> Http.handle("Bitbucket Data Center")
end

@spec post_json(map(), String.t(), map()) :: {:ok, term()} | {:error, String.t()}
Expand All @@ -68,32 +60,16 @@ defmodule Console.AI.Tools.Workbench.Integration.BitbucketDatacenter.Client do
url = base <> path
headers = auth_headers(token) ++ [{"Content-Type", "application/json"}]

case HTTPoison.post(url, Jason.encode!(body_map), headers, http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Bitbucket Data Center API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Bitbucket Data Center", reason)
end
Req.post(url, [headers: headers, body: Jason.encode!(body_map)] ++ http_opts())
|> Http.handle("Bitbucket Data Center")
end

@spec put_empty(map(), String.t()) :: {:ok, term()} | {:error, String.t()}
def put_empty(%{token: token}, url) when is_binary(url) do
headers = auth_headers(token) ++ [{"Content-Type", "application/json"}]

case HTTPoison.put(url, "", headers, http_opts()) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 ->
decode_json(body)

{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, "Bitbucket Data Center API #{code}: #{inspect(body)}"}

{:error, reason} ->
Http.error("Bitbucket Data Center", reason)
end
Req.put(url, [headers: headers, body: ""] ++ http_opts())
|> Http.handle("Bitbucket Data Center")
end

@doc false
Expand Down Expand Up @@ -143,17 +119,10 @@ defmodule Console.AI.Tools.Workbench.Integration.BitbucketDatacenter.Client do
]
end

defp decode_json(""), do: {:ok, %{}}

defp decode_json(body) do
case Jason.decode(body) do
{:ok, data} -> {:ok, data}
{:error, _} -> {:error, "Bitbucket Data Center returned non-JSON body: #{inspect(body)}"}
end
end

defp http_opts,
do:
Application.get_env(:console, :httpoison_bitbucket_datacenter_options, []) ++
[recv_timeout: 60_000]
Console.Utils.HTTP.client_options(
:httpoison_bitbucket_datacenter_options,
:req_bitbucket_datacenter_options
)
end
Loading
Loading