From 95d833874ad232452aad1fc8688a4549eb1f4a47 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Sat, 14 Feb 2026 16:08:27 -0600 Subject: [PATCH] Gaiia webhook: verify secret via query param instead of signature header --- .../api/v1/gaiia_webhook_controller.ex | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 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 8abd6e28..545e203e 100644 --- a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex +++ b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex @@ -2,8 +2,13 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do @moduledoc """ Webhook endpoint for receiving real-time updates from Gaiia. - Gaiia sends unsigned webhooks via axios. We verify the organization exists - and has an active Gaiia integration, then process the event. + 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: + + https://towerops.net/api/v1/webhooks/gaiia/?secret= + + If no webhook_secret is configured in the integration, all requests are accepted. Route: POST /api/v1/webhooks/gaiia/:organization_id """ @@ -14,8 +19,9 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do require Logger - def create(conn, %{"organization_id" => organization_id}) do - with {:ok, _integration} <- get_gaiia_integration(organization_id), + def create(conn, %{"organization_id" => organization_id} = params) do + with {:ok, integration} <- get_gaiia_integration(organization_id), + :ok <- verify_secret(integration, params), {: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", %{})) @@ -35,6 +41,24 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do end end + defp verify_secret(integration, params) do + case get_in(integration.credentials, ["webhook_secret"]) do + nil -> + # No secret configured — accept all requests + :ok + + "" -> + :ok + + expected_secret -> + if Plug.Crypto.secure_compare(params["secret"] || "", expected_secret) do + :ok + else + {:error, :invalid_secret} + end + 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} @@ -45,6 +69,9 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Gaiia integration not found"}) + {:error, :invalid_secret} -> + conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook secret"}) + {:error, :missing_event} -> conn |> put_status(:bad_request) |> json(%{error: "Missing event field"})