3.4 KiB · text History b868907
defmodule GitGudWeb.Plugs.HookAuth do
  @moduledoc """
  Guards the endpoints only this node's own git hooks and SSH shim may
  call: `/_hooks/pre-receive`, `/_hooks/post-receive` and `/_ssh/resolve`.

  They used to be guarded by nothing but a comment saying "bind to
  127.0.0.1", while the endpoint listens on every interface. Anyone could
  then ask `/_ssh/resolve` which key may push where, or post a fake
  post-receive naming any pusher and start workflows as them.

  A request must carry `x-gitgud-hook-token` equal to a secret this node
  holds. `put_secret/0` makes it at boot (or takes `GITGUD_HOOK_SECRET`
  when the operator sets one) and exports it into the node's own OS
  environment, so every `git` it spawns — `git http-backend` for smart
  HTTP, `git-receive-pack` for the embedded SSH daemon — hands it down
  to the hooks, the way `GITGUD_PUSHER_ID` already travels. The scripts
  in `priv/scripts` send it.

  A secret rather than a loopback-address check: behind a reverse proxy
  on the same host (the usual way to terminate TLS for the .deb install)
  or a mesh sidecar, every outside request arrives from 127.0.0.1 too,
  so the peer address proves nothing; and a hook pointed at the node by
  name through `GITGUD_HOOK_URL` would stop working.

  The OpenSSH `authorized_keys` shim (`gitgud-ssh`) runs under sshd, not
  under this node, so with that setup set `GITGUD_HOOK_SECRET` to the
  same value in both environments.
  """

  @behaviour Plug

  import Plug.Conn

  require Logger

  @env "GITGUD_HOOK_SECRET"
  @header "x-gitgud-hook-token"
  @key {__MODULE__, :secret}

  @doc "The header the hook scripts send the secret in."
  def header, do: @header

  @doc """
  Settle this node's secret and export it to the OS environment the
  node's children inherit. Called once from `GitGud.Application.start/2`.
  """
  def put_secret do
    secret =
      case System.get_env(@env) do
        s when is_binary(s) and byte_size(s) >= 16 -> s
        _ -> 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
      end

    System.put_env(@env, secret)
    :persistent_term.put(@key, secret)
    warn_fail_open()
    :ok
  end

  # The hooks inherit this node's environment, so an operator who set
  # the pre-receive escape hatch here set it for every push. Say so at
  # boot: it's meant for an emergency, not to be forgotten in a unit file.
  defp warn_fail_open do
    if System.get_env("GITGUD_PREHOOK_FAIL_OPEN") == "1" do
      Logger.warning(
        "GITGUD_PREHOOK_FAIL_OPEN=1: pushes are let through WITHOUT branch protection " <>
          "whenever the pre-receive backend is unreachable or failing (5xx). " <>
          "Unset it once the emergency is over."
      )
    end
  end

  @doc "This node's secret."
  def secret do
    case :persistent_term.get(@key, nil) do
      nil ->
        :ok = put_secret()
        :persistent_term.get(@key)

      s ->
        s
    end
  end

  @impl true
  def init(opts), do: opts

  @impl true
  def call(conn, _opts) do
    case get_req_header(conn, @header) do
      [token] when is_binary(token) ->
        if Plug.Crypto.secure_compare(token, secret()), do: conn, else: deny(conn)

      _ ->
        deny(conn)
    end
  end

  defp deny(conn) do
    Logger.warning(
      "refused #{conn.method} #{conn.request_path} from #{:inet.ntoa(conn.remote_ip)}: " <>
        "no valid #{@header}"
    )

    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(403, "DENY forbidden\n")
    |> halt()
  end
end