fix(api-keys): la restricción por IP comparaba contra la IP del proxy, no la del cliente

c.IP() detrás del proxy del hosting devuelve la IP interna del contenedor que
reenvía (10.x.x.x). Contra eso, ninguna IP pública cargada en una llave podía
coincidir: toda API key con restricción de IP daba 403, que es exactamente lo
que le pasó a la integración de vCard.

Ahora se lee X-Forwarded-For, y se toma la ÚLTIMA entrada, no la primera: con
un proxy adelante esa es la que escribió el proxy. Si quien llama manda su
propio X-Forwarded-For, el proxy le agrega la IP real al final — quedarse con
la primera sería dejar que cada uno declare su IP y la restricción no valdría
nada. El test fija ese caso.

Mismo arreglo en la autenticación de Pagos Externos, que comparaba igual.

El 403 ahora dice qué IP se vio y cuál está permitida: sin eso, del otro lado
se prueba a ciegas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 21:02:45 -05:00
co-authored by Claude Opus 5
parent dda5898c0d
commit c75a6d7deb
4 changed files with 88 additions and 4 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ func AuthServicioPago(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Token inválido o servicio inactivo"})
}
if !servicio.IPPermitida(c.IP()) {
if !servicio.IPPermitida(IPDelCliente(c)) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": true, "message": "IP no autorizada para este servicio"})
}
+9 -3
View File
@@ -38,11 +38,17 @@ func AdminApiAuth() fiber.Handler {
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "unauthorized"})
}
if !key.IPValida(c.IP()) {
return c.Status(403).JSON(fiber.Map{"error": "IP no autorizada para esta API key"})
if ip := IPDelCliente(c); !key.IPValida(ip) {
// Decir qué IP se vio: sin eso, del otro lado no hay forma de saber
// qué cargar en la llave y se prueba a ciegas.
return c.Status(403).JSON(fiber.Map{
"error": "IP no autorizada para esta API key",
"ip_vista": ip,
"ip_permitida": key.IPPermitida,
})
}
go models.RegistrarUsoApiKey(key.ID, c.IP())
go models.RegistrarUsoApiKey(key.ID, IPDelCliente(c))
c.Locals("api_key", key)
return c.Next()
}
+30
View File
@@ -0,0 +1,30 @@
package middlewares
import (
"strings"
"github.com/gofiber/fiber/v2"
)
// IPDelCliente devuelve la IP pública desde la que se hizo la petición.
//
// c.IP() no sirve para esto en producción: detrás del proxy del hosting
// devuelve la IP interna del proxy (10.x.x.x), así que ninguna IP pública
// autorizada coincidía nunca y toda llave con restricción de IP daba 403.
//
// Se toma la ÚLTIMA entrada de X-Forwarded-For, no la primera: con un solo
// proxy adelante, esa es la que escribió el proxy y no la puede falsear quien
// llama. Si el cliente manda su propio X-Forwarded-For, el proxy le agrega la
// IP real al final — quedarse con la primera sería creerle al que llama.
func IPDelCliente(c *fiber.Ctx) string {
if xff := c.Get("X-Forwarded-For"); xff != "" {
partes := strings.Split(xff, ",")
if ip := strings.TrimSpace(partes[len(partes)-1]); ip != "" {
return ip
}
}
if real := strings.TrimSpace(c.Get("X-Real-Ip")); real != "" {
return real
}
return c.IP()
}
+48
View File
@@ -0,0 +1,48 @@
package middlewares
import (
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestIPDelCliente(t *testing.T) {
casos := []struct {
nombre string
xff string
real string
want string
}{
{"un solo proxy", "46.202.93.92", "", "46.202.93.92"},
// Si quien llama manda su propio X-Forwarded-For, el proxy le agrega la
// IP real al final. Quedarse con la primera sería dejar que elija su IP.
{"cliente intentando falsear", "1.2.3.4, 46.202.93.92", "", "46.202.93.92"},
{"con espacios", " 10.0.0.1 , 46.202.93.92 ", "", "46.202.93.92"},
{"sin xff, con x-real-ip", "", "46.202.93.92", "46.202.93.92"},
}
app := fiber.New()
app.Get("/ip", func(c *fiber.Ctx) error {
return c.SendString(IPDelCliente(c))
})
for _, cs := range casos {
req := httptest.NewRequest("GET", "/ip", nil)
if cs.xff != "" {
req.Header.Set("X-Forwarded-For", cs.xff)
}
if cs.real != "" {
req.Header.Set("X-Real-Ip", cs.real)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("%s: %v", cs.nombre, err)
}
buf := make([]byte, 64)
n, _ := resp.Body.Read(buf)
if got := string(buf[:n]); got != cs.want {
t.Errorf("%s: IPDelCliente = %q, want %q", cs.nombre, got, cs.want)
}
}
}