From be5fa8f4d7a1de85a7b64512c1dcc0327f486341 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Sat, 14 Feb 2026 16:28:35 -0600 Subject: [PATCH] Gaiia webhook: proper HMAC verification per docs, optional when no header --- .../api/v1/gaiia_webhook_controller.ex | 91 +++++++++++++++++-- 1 file changed, 82 insertions(+), 9 deletions(-) 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 8241a5e7..32079a2c 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,13 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do @moduledoc """ Webhook endpoint for receiving real-time updates from Gaiia. - Gaiia doesn't sign webhook requests. Instead, a shared secret is verified - via a `secret` query parameter in the webhook URL. When configuring the - webhook in Gaiia, use: + Gaiia signs webhooks with `X-Gaiia-Webhook-Signature` header using HMAC-SHA256. + Format: `t=,v1=` + Signed payload: `.` - https://towerops.net/api/v1/webhooks/gaiia/?secret= - - If no webhook_secret is configured in the integration, all requests are accepted. + 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). Route: POST /api/v1/webhooks/gaiia/:organization_id """ @@ -19,10 +19,12 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do require Logger + @max_age_seconds 300 + def create(conn, %{"organization_id" => organization_id}) do - with {:ok, _integration} <- get_gaiia_integration(organization_id), + with {:ok, integration} <- get_gaiia_integration(organization_id), + :ok <- maybe_verify_signature(conn, integration), {:ok, event} <- get_event(conn.body_params) do - # Gaiia sends object data in "payload" field 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} @@ -40,16 +42,87 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do end end + defp maybe_verify_signature(conn, integration) do + secret = get_in(integration.credentials, ["webhook_secret"]) + sig_header = get_req_header(conn, "x-gaiia-webhook-signature") + + case {secret, sig_header} do + # No secret configured — accept without verification + {nil, _} -> + :ok + + {"", _} -> + :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 + {secret, [header]} -> + raw_body = conn.private[:raw_body] || "" + verify_signature(header, raw_body, secret) + end + end + + defp verify_signature(header, raw_body, secret) do + with {:ok, timestamp, signature} <- parse_signature_header(header), + :ok <- check_timestamp(timestamp) do + signed_payload = timestamp <> "." <> raw_body + + computed = + :hmac + |> :crypto.mac(:sha256, secret, signed_payload) + |> Base.encode16(case: :lower) + + if Plug.Crypto.secure_compare(computed, signature) do + :ok + else + Logger.warning("Gaiia webhook signature mismatch") + {:error, :invalid_signature} + end + end + end + + 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) + + 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} + end + rescue + _ -> {:error, :invalid_signature} + end + + defp check_timestamp(timestamp) do + case Integer.parse(timestamp) do + {ts, ""} -> + now = System.system_time(:second) + if abs(now - ts) <= @max_age_seconds, do: :ok, else: {:error, :invalid_signature} + + _ -> + {:error, :invalid_signature} + 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} - # Override action/2 to handle with clause failures def action(conn, _opts) do case apply(__MODULE__, action_name(conn), [conn, conn.params]) do {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Gaiia integration not found"}) + {: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"})