diff --git a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex index 773f9e00..083b600f 100644 --- a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex +++ b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex @@ -2,13 +2,21 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do @moduledoc """ Webhook endpoint for receiving real-time updates from Gaiia. - Gaiia signs webhooks with `X-Gaiia-Webhook-Signature` header using HMAC-SHA256. - Format: `t=,v1=` - Signed payload: `.` + Implements signature verification per Gaiia's webhook security docs: + https://app.gaiia.com/docs/webhooks/security - If a webhook_secret is configured AND the signature header is present, - we verify it. Otherwise we accept the request (allows setup before - configuring the secret in Gaiia). + ## Gaiia's verification algorithm: + + 1. **Extract** timestamp (`t`) and signature (`v1`) from the + `X-Gaiia-Webhook-Signature` header. Format: `t=,v1=` + + 2. **Prepare** the signed payload: `.` + + 3. **Compute** HMAC-SHA256 of the signed payload using the endpoint's + secret key. Hex-encode the result. + + 4. **Compare** the computed signature with `v1` from the header. + Also check that the timestamp is within acceptable tolerance. Route: POST /api/v1/webhooks/gaiia/:organization_id """ @@ -21,20 +29,36 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do @max_age_seconds 300 + # ── Main action ── + def create(conn, %{"organization_id" => organization_id}) do with {:ok, integration} <- get_gaiia_integration(organization_id), - :ok <- maybe_verify_signature(conn, integration), - {:ok, event} <- get_event(conn.body_params) do + :ok <- verify_webhook(conn, integration) do + process_webhook(conn, organization_id) + end + end + + defp process_webhook(conn, organization_id) do + event = Map.get(conn.body_params, "eventName") || Map.get(conn.body_params, "event") + + if is_nil(event) do + Logger.warning("Gaiia webhook missing eventName: #{inspect(Map.keys(conn.body_params))}") + conn |> put_status(:bad_request) |> json(%{error: "Missing eventName field"}) + else data = Map.get(conn.body_params, "payload", Map.get(conn.body_params, "data", %{})) entity_id = Map.get(conn.body_params, "objectId", Map.get(data, "id")) - payload = %{"entity_id" => entity_id, "data" => data} - Webhooks.process_event(organization_id, event, payload) + Webhooks.process_event(organization_id, event, %{ + "entity_id" => entity_id, + "data" => data + }) json(conn, %{status: "ok"}) end end + # ── Integration lookup ── + defp get_gaiia_integration(organization_id) do case Integrations.get_integration(organization_id, "gaiia") do {:ok, integration} -> {:ok, integration} @@ -42,93 +66,98 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do end end - defp maybe_verify_signature(conn, integration) do + # ── Signature verification per Gaiia docs ── + + defp verify_webhook(conn, integration) do secret = get_in(integration.credentials, ["webhook_secret"]) - sig_header = get_req_header(conn, "x-gaiia-webhook-signature") + sig_headers = Plug.Conn.get_req_header(conn, "x-gaiia-webhook-signature") - case {secret, sig_header} do - # No secret configured — accept without verification - {nil, _} -> + cond do + is_nil(secret) or secret == "" -> :ok - {"", _} -> + sig_headers == [] -> + Logger.warning("Gaiia webhook: secret configured but no signature header received") :ok - # Secret configured but no signature header — accept (Gaiia may not have secret set yet) - {_secret, []} -> - Logger.warning("Gaiia webhook received without signature header, skipping verification") - :ok - - # Both present — verify (log failures but accept anyway) - {secret, [header]} -> + true -> raw_body = conn.private[:raw_body] || "" - - case verify_signature(header, raw_body, secret) do - :ok -> - Logger.info("Gaiia webhook signature verified successfully") - :ok - - {:error, _} -> - Logger.warning( - "Gaiia webhook signature verification failed — accepting anyway. " <> - "Header: #{inspect(String.slice(header, 0, 40))}..., " <> - "Body length: #{byte_size(raw_body)}, " <> - "Secret length: #{byte_size(secret)}" - ) - - :ok - end + verify_gaiia_signature(hd(sig_headers), raw_body, secret) end end - defp verify_signature(header, raw_body, secret) do - with {:ok, timestamp, signature} <- parse_signature_header(header), + # Step 1: Parse the header to extract t and v1 + # Step 2-4: Build signed payload, compute HMAC, compare + defp verify_gaiia_signature(header, raw_body, secret) do + with {:ok, timestamp, v1_signature} <- parse_signature_header(header), :ok <- check_timestamp(timestamp) do + # Step 2: signedPayload = "." signed_payload = timestamp <> "." <> raw_body - computed = + # Step 3: HMAC-SHA256, hex-encoded lowercase + expected = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower) - if Plug.Crypto.secure_compare(computed, signature) do + # Step 4: Constant-time comparison + if Plug.Crypto.secure_compare(expected, v1_signature) do :ok else - Logger.warning("Gaiia webhook signature mismatch") + Logger.warning( + "Gaiia webhook signature mismatch — " <> + "ts=#{timestamp} body_len=#{byte_size(raw_body)} " <> + "secret_len=#{byte_size(secret)} " <> + "expected=#{String.slice(expected, 0, 12)}… " <> + "received=#{String.slice(v1_signature, 0, 12)}…" + ) + {:error, :invalid_signature} end + else + {:error, :malformed} -> + Logger.warning("Gaiia webhook: malformed signature header: #{inspect(header)}") + {:error, :invalid_signature} + + {:error, :expired} -> + Logger.warning("Gaiia webhook: timestamp expired (>#{@max_age_seconds}s old)") + {:error, :invalid_signature} end end + # Parse "t=1492774577,v1=5257a869..." into {:ok, "1492774577", "5257a869..."} defp parse_signature_header(header) do parts = header |> String.split(",") - |> Enum.map(&String.split(&1, "=", parts: 2)) - |> Map.new(fn [k, v] -> {String.trim(k), v} end) + |> Enum.reduce(%{}, fn element, acc -> + case String.split(element, "=", parts: 2) do + [key, value] -> Map.put(acc, String.trim(key), value) + _ -> acc + end + end) case {Map.get(parts, "t"), Map.get(parts, "v1")} do {t, v1} when is_binary(t) and is_binary(v1) -> {:ok, t, v1} - _ -> {:error, :invalid_signature} + _ -> {:error, :malformed} end - rescue - _ -> {:error, :invalid_signature} end - defp check_timestamp(timestamp) do - case Integer.parse(timestamp) do + # Step 4 (from docs): "calculate the difference between the current + # timestamp and the received timestamp, then decide if the difference + # is within your tolerance" + defp check_timestamp(timestamp_str) do + case Integer.parse(timestamp_str) do {ts, ""} -> - now = System.system_time(:second) - if abs(now - ts) <= @max_age_seconds, do: :ok, else: {:error, :invalid_signature} + age = abs(System.system_time(:second) - ts) + if age <= @max_age_seconds, do: :ok, else: {:error, :expired} _ -> - {:error, :invalid_signature} + {:error, :malformed} end end - defp get_event(%{"eventName" => event}) when is_binary(event), do: {:ok, event} - defp get_event(%{"event" => event}) when is_binary(event), do: {:ok, event} - defp get_event(_), do: {:error, :missing_event} + # ── Error handling ── def action(conn, _opts) do case apply(__MODULE__, action_name(conn), [conn, conn.params]) do @@ -138,8 +167,8 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do {:error, :invalid_signature} -> conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"}) - {:error, :missing_event} -> - conn |> put_status(:bad_request) |> json(%{error: "Missing event field"}) + %Plug.Conn{} = conn -> + conn other -> other