From 39235aa49e41f9265cc58294fd6121c18f4001b7 Mon Sep 17 00:00:00 2001 From: Aitor Reviriego Amor Date: Tue, 2 Jun 2026 22:09:52 +0200 Subject: [PATCH] chore(security): hardening de la demo (max-instances, /refresh con token, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/sales/refresh: si Refresh:Token está configurado, exige el header X-Refresh-Token (comparación de tiempo constante); si no, abierto (default demo). El frontend ya no lo llama. Evita que cualquiera dispare la re-ingesta contra la fuente real. - scripts/deploy-cloudrun.sh + DEPLOY.md: --max-instances 3 en ambos servicios (acota coste/DoS de los servicios públicos). - DEPLOY.md: sección 'Security notes' (API pública con datos ficticios, token de refresh, Secret Manager + --set-secrets para SAP/Shopify, errores saneados). - Tests (76 -> 78): /refresh con token rechaza sin header y acepta con header. Co-Authored-By: Claude Opus 4.8 --- DEPLOY.md | 27 +++++++++- .../Inbound/Http/SalesController.cs | 21 +++++++- .../Api/RefreshEndpointTests.cs | 52 +++++++++++++++---- scripts/deploy-cloudrun.sh | 4 +- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index fb99da4..b64f3da 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -34,7 +34,7 @@ gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregi gcloud run deploy connect-analyzer-mock \ --source backend/mocks/sap \ --region europe-southwest1 \ - --port 8080 --allow-unauthenticated + --port 8080 --allow-unauthenticated --max-instances 3 # Grab its URL MOCK_URL=$(gcloud run services describe connect-analyzer-mock \ @@ -44,10 +44,12 @@ MOCK_URL=$(gcloud run services describe connect-analyzer-mock \ gcloud run deploy connect-analyzer-api \ --source backend \ --region europe-southwest1 \ - --port 8080 --allow-unauthenticated \ + --port 8080 --allow-unauthenticated --max-instances 3 \ --set-env-vars "SalesSource=Mock,SapMock__BaseUrl=${MOCK_URL},Sqlite__Path=/tmp/sales.db" ``` +`--max-instances 3` caps the cost/DoS blast radius of the public, unauthenticated services. + Smoke-test (URLs are printed by the deploys / `gcloud run services describe`): ```bash @@ -94,3 +96,24 @@ Cors__AllowedOrigins__0 = https://.vercel.app ``` Never widen to `AllowAnyOrigin`. + +## Security notes + +The demo serves **fictitious data** over a **public, unauthenticated** API (Cloud Run +`--allow-unauthenticated`). Residual risks and how they're handled: + +- **Cost / DoS abuse** → `--max-instances 3` caps scaling, and a **billing budget alert** is set + on the project. Reading `/api/sales*` only exposes the demo dataset. +- **`POST /api/sales/refresh`** (re-ingestion) can be protected with a token: set + `Refresh__Token=` on the `connect-analyzer-api` service, then callers must send a matching + `X-Refresh-Token` header (constant-time compared). Left unset, the endpoint stays open (demo + default). The frontend never calls it. +- **Real SAP/Shopify secrets** → use **Secret Manager + `--set-secrets`** instead of plain + `--set-env-vars`, so the values aren't visible in the service config: + ```bash + gcloud secrets create sap-api-key --data-file=- <<< "" + gcloud run services update connect-analyzer-api --region europe-southwest1 \ + --update-secrets Sap__ApiKey=sap-api-key:latest + ``` +- App errors return **sanitized** `ProblemDetails` (no internal/upstream messages); the real detail + is logged server-side. SQLite access is parameterized; HTTPS is enforced by Cloud Run. diff --git a/backend/Infrastructure/Inbound/Http/SalesController.cs b/backend/Infrastructure/Inbound/Http/SalesController.cs index d0a45bc..7620cfb 100644 --- a/backend/Infrastructure/Inbound/Http/SalesController.cs +++ b/backend/Infrastructure/Inbound/Http/SalesController.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Microsoft.AspNetCore.Mvc; using ConnectAnalyzer.Application; using ConnectAnalyzer.Domain; @@ -9,15 +11,32 @@ namespace ConnectAnalyzer.Infrastructure.Inbound.Http; public sealed class SalesController( SalesAnalytics analytics, IngestSales ingest, - ILogger logger) : ControllerBase + ILogger logger, + IConfiguration configuration) : ControllerBase { + private const string RefreshTokenHeader = "X-Refresh-Token"; + + // Re-ingestion is a write/admin action. If Refresh:Token is configured, require it in the + // X-Refresh-Token header (constant-time compare); if it isn't, the endpoint stays open + // (demo default). Protects against anyone triggering ingestion against the real source. [HttpPost("refresh")] public async Task Refresh(CancellationToken ct) { + var requiredToken = configuration["Refresh:Token"]; + if (!string.IsNullOrEmpty(requiredToken) && !RefreshTokenMatches(requiredToken)) + return Unauthorized(); + var result = await ingest.ExecuteAsync(ct); return result.Match(ingested => Ok(new { ingested }), Fail); } + private bool RefreshTokenMatches(string requiredToken) + { + var provided = Request.Headers[RefreshTokenHeader].ToString(); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(provided), Encoding.UTF8.GetBytes(requiredToken)); + } + [HttpGet] public async Task GetAll(CancellationToken ct) { diff --git a/backend/tests/ConnectAnalyzer.Tests/Api/RefreshEndpointTests.cs b/backend/tests/ConnectAnalyzer.Tests/Api/RefreshEndpointTests.cs index c7be01c..0f35a1f 100644 --- a/backend/tests/ConnectAnalyzer.Tests/Api/RefreshEndpointTests.cs +++ b/backend/tests/ConnectAnalyzer.Tests/Api/RefreshEndpointTests.cs @@ -23,17 +23,7 @@ public class RefreshEndpointTests public async Task Refresh_IngestsFromSourceIntoStoreAndReturnsCount() { var store = StubSalesStore.Containing(); // starts empty - using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => - { - builder.UseEnvironment("Testing"); - builder.ConfigureServices(services => - { - services.RemoveAll(typeof(ISalesRepository)); - services.AddSingleton(new FakeSalesRepository()); - services.RemoveAll(typeof(ISalesStore)); - services.AddSingleton(store); - }); - }); + using var factory = CreateFactory(store, refreshToken: null); var client = factory.CreateClient(); var response = await client.PostAsync("/api/sales/refresh", content: null); @@ -45,6 +35,46 @@ public async Task Refresh_IngestsFromSourceIntoStoreAndReturnsCount() Assert.Equal(3, store.LastSaved!.Count); } + [Fact] + public async Task Refresh_WithTokenConfigured_RejectsRequestWithoutMatchingHeader() + { + using var factory = CreateFactory(StubSalesStore.Containing(), refreshToken: "s3cret"); + var client = factory.CreateClient(); + + var response = await client.PostAsync("/api/sales/refresh", content: null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Refresh_WithTokenConfigured_AcceptsMatchingHeader() + { + using var factory = CreateFactory(StubSalesStore.Containing(), refreshToken: "s3cret"); + var client = factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "/api/sales/refresh"); + request.Headers.Add("X-Refresh-Token", "s3cret"); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static WebApplicationFactory CreateFactory( + StubSalesStore store, string? refreshToken) => + new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseEnvironment("Testing"); + if (refreshToken is not null) + builder.UseSetting("Refresh:Token", refreshToken); + builder.ConfigureServices(services => + { + services.RemoveAll(typeof(ISalesRepository)); + services.AddSingleton(new FakeSalesRepository()); + services.RemoveAll(typeof(ISalesStore)); + services.AddSingleton(store); + }); + }); + private record RefreshResponse(int Ingested); private sealed class FakeSalesRepository : ISalesRepository diff --git a/scripts/deploy-cloudrun.sh b/scripts/deploy-cloudrun.sh index 8bdf425..bd23e0b 100755 --- a/scripts/deploy-cloudrun.sh +++ b/scripts/deploy-cloudrun.sh @@ -20,7 +20,8 @@ gcloud run deploy "$MOCK_SERVICE" \ --source backend/mocks/sap \ --region "$REGION" \ --port 8080 \ - --allow-unauthenticated + --allow-unauthenticated \ + --max-instances 3 MOCK_URL="$(gcloud run services describe "$MOCK_SERVICE" --region "$REGION" --format='value(status.url)')" echo "✔ Mock en: $MOCK_URL" @@ -31,6 +32,7 @@ gcloud run deploy "$API_SERVICE" \ --region "$REGION" \ --port 8080 \ --allow-unauthenticated \ + --max-instances 3 \ --set-env-vars "SalesSource=Mock,SapMock__BaseUrl=${MOCK_URL},Sqlite__Path=/tmp/sales.db" API_URL="$(gcloud run services describe "$API_SERVICE" --region "$REGION" --format='value(status.url)')"