diff --git a/infra/caddy/Caddyfile.prod b/infra/caddy/Caddyfile.prod index f417638a..5cda8df0 100644 --- a/infra/caddy/Caddyfile.prod +++ b/infra/caddy/Caddyfile.prod @@ -48,6 +48,50 @@ oullin.io { respond 403 } + # --- Browser-facing signature relay + @relay_signature_cors path /relay/generate-signature* + header @relay_signature_cors Access-Control-Allow-Origin "https://oullin.io" + header @relay_signature_cors Access-Control-Allow-Methods "POST, OPTIONS" + header @relay_signature_cors Access-Control-Allow-Headers "X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match, X-API-Intended-Origin" + header @relay_signature_cors Vary "Origin" + + @relay_signature_preflight { + path /relay/generate-signature* + method OPTIONS + } + handle @relay_signature_preflight { + header Access-Control-Max-Age "86400" + respond 204 + } + + @relay_signature_post { + path /relay/generate-signature* + method POST + } + handle @relay_signature_post { + uri strip_prefix /relay + reverse_proxy api:8080 { + header_up Host {host} + + header_up X-API-Username {http.request.header.X-API-Username} + header_up X-API-Key {http.request.header.X-API-Key} + header_up X-API-Timestamp {http.request.header.X-API-Timestamp} + header_up X-Request-ID {http.request.header.X-Request-ID} + header_up Content-Type {http.request.header.Content-Type} + header_up User-Agent {http.request.header.User-Agent} + header_up X-API-Intended-Origin {http.request.header.X-API-Intended-Origin} + + transport http { + dial_timeout 10s + response_header_timeout 30s + } + } + } + + handle /relay/generate-signature* { + respond 405 + } + @preflight { method OPTIONS header Origin * diff --git a/infra/caddy/caddyfile_prod_test.go b/infra/caddy/caddyfile_prod_test.go index 043f4d8d..1b1a8afa 100644 --- a/infra/caddy/caddyfile_prod_test.go +++ b/infra/caddy/caddyfile_prod_test.go @@ -30,8 +30,6 @@ func protectedPublicPaths(caddyfile string) map[string]bool { for _, path := range fields[2:] { paths[path] = true } - - return paths } return paths @@ -93,6 +91,41 @@ func TestProdCaddyfileBlocksPublicSignatureEndpoint(t *testing.T) { } } +func TestProdCaddyfileHandlesBrowserSignatureRelayAtEdge(t *testing.T) { + caddyfile := stripCaddyComments(readProdCaddyfile(t)) + + relayContract := regexp.MustCompile(`(?s)` + + `@relay_signature_cors\s+path\s+/relay/generate-signature\*.*?` + + `header\s+@relay_signature_cors\s+Access-Control-Allow-Origin\s+"https://oullin\.io".*?` + + `header\s+@relay_signature_cors\s+Access-Control-Allow-Methods\s+"POST, OPTIONS".*?` + + `header\s+@relay_signature_cors\s+Access-Control-Allow-Headers\s+"X-API-Key, X-API-Username, X-API-Signature, X-API-Timestamp, X-API-Nonce, X-Request-ID, Content-Type, User-Agent, If-None-Match, X-API-Intended-Origin".*?` + + `@relay_signature_preflight\s*{\s*` + + `path\s+/relay/generate-signature\*\s+` + + `method\s+OPTIONS\s*` + + `}.*?` + + `handle\s+@relay_signature_preflight\s*{\s*` + + `header\s+Access-Control-Max-Age\s+"86400"\s+` + + `respond\s+204\s*` + + `}.*?` + + `@relay_signature_post\s*{\s*` + + `path\s+/relay/generate-signature\*\s+` + + `method\s+POST\s*` + + `}.*?` + + `handle\s+@relay_signature_post\s*{\s*` + + `uri\s+strip_prefix\s+/relay\s+` + + `reverse_proxy\s+api:8080\s*{.*?` + + `header_up\s+X-API-Intended-Origin\s+\{http\.request\.header\.X-API-Intended-Origin\}.*?` + + `}`) + + if !relayContract.MatchString(caddyfile) { + t.Fatal("expected /relay/generate-signature* to be handled by the public edge and proxied to api:8080") + } + + if !regexp.MustCompile(`handle\s+/relay/generate-signature\*\s*{\s*respond\s+405\s*}`).MatchString(caddyfile) { + t.Fatal("expected unsupported relay signature methods to respond with 405") + } +} + func TestProdCaddyfileKeepsSignatureEndpointBehindMTLS(t *testing.T) { caddyfile := readProdCaddyfile(t) mtlsBlock, ok := caddyBlock(stripCaddyComments(caddyfile), ":8443")