feat(redirects): serve ASO redirect overlay via api-service (Lite-E)#2638
Conversation
Add GET /config/:env/:service/redirects.txt, which validates the inbound X-ASO-API-Key, then reads config/cm-pXXX-eYYY/redirects.txt from the per-env overlay bucket (S3_ASO_OVERLAYS_BUCKET) with the Lambda's own execution role and returns it as text/plain. This is "Lite-E" from ADR aso-dispatcher-overlay (mysticat-architecture#159): Fastly proxies /config/* to api-service instead of signing SigV4 to S3 directly, which removes the SigV4-in-VCL and static-IAM-key fragility (supersedes spacecat-infrastructure#616/#617) and ends public S3 origin enumeration. The dispatcher is unchanged (still GET + X-ASO-API-Key). Auth uses the established hooks pattern: the route is anonymous to the standard chain (access-control-util ANONYMOUS_ENDPOINTS) and the controller enforces the key with a constant-time compare. Route added to INTERNAL_ROUTES (not S2S). Secrets S3_ASO_OVERLAYS_BUCKET + ASO_OVERLAY_API_KEY documented in .env.example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
This PR will trigger a minor release when merged. |
There was a problem hiding this comment.
Hey @alinarublea,
Verdict: Request changes - two small fixes needed before this is ready.
Changes: adds a new GET /config/:env/:service/redirects.txt endpoint that serves ASO redirect overlays from S3, authenticated by a static API key validated in the controller (8 files).
Must fix before merge
- [Important]
safeEqualleaks API key length via timing side-channel on length mismatch -src/controllers/redirects.js:43(details inline) - [Important]
:envpath parameter is validated but never used in S3 key construction -src/controllers/redirects.js:113(details inline)
Non-blocking (5): minor issues and suggestions
- suggestion: Add
log.warn('[aso-overlay] auth failed', { hasKey: !!providedKey })before the 401 return so repeated failures produce an observability signal for abuse detection -src/controllers/redirects.js:91 - suggestion: Tighten the ANONYMOUS_ENDPOINTS regex service segment from
[^/]+tocm-p\d+-e\d+to match the controller's own validation (defense-in-depth) -src/support/access-control-util.js:29 - nit: Cap SERVICE_RE digit sequences to realistic lengths, e.g.
/^cm-p\d{1,10}-e\d{1,10}$/, to prevent arbitrarily long S3 key lookups -src/controllers/redirects.js:25 - suggestion: Add a test case for the
err.$metadata?.httpStatusCode === 404fallback branch (currently onlyerr.name === 'NoSuchKey'is exercised) -test/controllers/redirects.test.js - suggestion: Add a test for the new anonymous endpoint regex in
access-control-util.test.jsto verify the auth bypass boundary matches expectations -src/support/access-control-util.js:29
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 5m 28s | Cost: $6.26 | Commit: 3a68cd5bee536c9c972e598f233b871d2afde8e2
If this code review was useful, please react with 👍. Otherwise, react with 👎.
| } | ||
| const ab = Buffer.from(a); | ||
| const bb = Buffer.from(b); | ||
| if (ab.length !== bb.length) { |
There was a problem hiding this comment.
issue (blocking): safeEqual returns early on length mismatch (line 46-48), creating a timing oracle that leaks the configured API key length. The current JSDoc claims "without a timing side channel" which is inaccurate - the early return IS a measurable timing difference. An attacker measuring response times over many requests can binary-search the key length.
Fix: Hash both inputs to a fixed length before comparing:
function safeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') {
return false;
}
const ha = crypto.createHmac('sha256', 'aso-key-compare').update(a).digest();
const hb = crypto.createHmac('sha256', 'aso-key-compare').update(b).digest();
return crypto.timingSafeEqual(ha, hb);
}This eliminates the length oracle at negligible cost. The HMAC key is not a secret here - it just normalizes both inputs to 32 bytes regardless of input length.
| } | ||
|
|
||
| // Read the overlay with the Lambda's own execution role (no SigV4 from caller). | ||
| const key = `config/${service}/redirects.txt`; |
There was a problem hiding this comment.
issue (blocking): The :env path parameter is validated against the ENVS allowlist (line 84) but is never used in the S3 key. The key is always config/${service}/redirects.txt regardless of whether the caller requests /config/dev/... or /config/prod/....
Since S3_ASO_OVERLAYS_BUCKET is per-deployment (one bucket per env), this is functionally correct today - but the validated-yet-unused parameter is confusing. A future maintainer will reasonably assume it is a bug.
Fix (pick one):
- Add a one-line comment above the key construction explaining the design: the env in the URL is for Fastly routing compatibility only; the backend reads from whichever bucket its deployment is configured with.
- Or, if the intent is that
reqEnvshould match the deployment env, assertreqEnv === env.ENVIRONMENTand return 400 on mismatch. - Or, if the bucket is ever shared across envs, incorporate env into the key:
config/${reqEnv}/${service}/redirects.txt
MysticatBot review (#2638): - safeEqual: HMAC both inputs to a fixed 32-byte digest before timingSafeEqual, removing the length side channel (the early length-mismatch return leaked it). - Use the :env path param: reject when it doesn't match the deployment's bucket env (spacecat-<env>-aso-overlays) so a dev deploy never answers for prod. - Log on auth failure for abuse-detection signal. - Tighten SERVICE_RE digit runs and the ANONYMOUS_ENDPOINTS regex service segment to cm-p<d>-e<d> (defense-in-depth). - Tests: $metadata 404 branch, env/bucket mismatch, and anonymous-endpoint regex boundary in access-control-util. OpenAPI: add docs/openapi/aso-overlay-api.yaml and wire the path + a new aso_overlay_api_key (X-ASO-API-Key) security scheme into api.yaml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…' into feat/aso-overlay-config-endpoint
There was a problem hiding this comment.
Hey @alinarublea,
Verdict: Request changes - one convention gap remains after an otherwise clean fix-up.
Changes: adds a new GET /config/:env/:service/redirects.txt endpoint serving ASO redirect overlays from S3, authenticated by a static API key validated in the controller (11 files).
Must fix before merge
- [Important] Missing integration tests per CLAUDE.md requirement -
test/it/(details inline)
Non-blocking (1): minor issues and suggestions
- nit: ANONYMOUS_ENDPOINTS regex uses unbounded
\d+while the controller'sSERVICE_REuses\d{1,10}- align for defense-in-depth consistency -src/support/access-control-util.js:30
Previously flagged, now resolved
safeEqualtiming side-channel: now HMAC-normalizes both inputs to fixed 32-byte digests beforetimingSafeEqual:envpath parameter unused: now validates requested env against the deployment bucket's env, returning 404 on mismatch- Auth failure logging added
- ANONYMOUS_ENDPOINTS regex tightened from
[^/]+tocm-p\d+-e\d+ - SERVICE_RE digit runs capped to
{1,10} - Test for
$metadata.httpStatusCode === 404fallback added - Access-control-util boundary test added
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 2m 6s | Cost: $4.43 | Commit: 473e9d42b83d3e79bcbc688a5fdd2e2fd2a2b393
If this code review was useful, please react with 👍. Otherwise, react with 👎.
| @@ -0,0 +1,148 @@ | |||
| /* | |||
There was a problem hiding this comment.
issue (blocking): The repo CLAUDE.md explicitly requires: "New or modified endpoints must include integration tests in test/it/". This PR adds a new endpoint but only includes unit tests.
The IT framework is PostgreSQL-focused and this endpoint is S3-only, so you may need to extend the harness or document why ITs are deferred (e.g. no S3/LocalStack support in the current IT infrastructure). If deferring, a follow-up ticket and a code comment noting the gap would satisfy the intent.
| /^POST \/slack\/events$/, | ||
| /^POST \/hooks\/site-detection.+/, | ||
| // ASO redirect overlay: authenticated by X-ASO-API-Key in RedirectsController | ||
| // (same pattern as the hooks above), not by the standard auth chain. |
There was a problem hiding this comment.
nit: The regex here uses unbounded \d+ while SERVICE_RE in the controller uses \d{1,10}. The controller is the enforcement boundary so this is not a security gap, but aligning them (cm-p\d{1,10}-e\d{1,10}) keeps defense-in-depth consistent and matches the stated intent from the commit message.
Make the ASO redirect-overlay endpoint the *real* Lite-E from the aso-dispatcher-overlay ADR: authorize every request before serving S3, so a holder of the shared X-ASO-API-Key can no longer enumerate other programs' overlays by walking cm-pX-eY. - Controller now resolves cm-pXXX-eYYY -> Site via the indexed findByExternalOwnerIdAndExternalSiteId(p<program>, e<env>) accessor, requires the site's org to hold an ASO entitlement AND the site to be enrolled in it, then reads S3. Every authz miss returns an indistinguishable 404 (no enumeration signal). - Drop the unused :env path segment: route is now GET /config/:service/redirects.txt (route table, required-capabilities, AsoOverlayKeyHandler regex, routes-contract test). The bucket is already env-scoped via the Lambda's own deployment. Edge/dispatcher URL change to be coordinated separately. - AuthN is owned by AsoOverlayKeyHandler (X-ASO-API-Key); the controller no longer re-checks the key, only authorizes. - Map S3 403 AccessDenied -> 404 for the caller (the reader role has no s3:ListBucket, so a missing object surfaces as 403), logged at error so a missing IAM grant stays alertable. See spacecat-infrastructure#620. - Integration test is self-contained: seeds throwaway entitled sites + ASO enrollments via PostgREST in before()/after() so the count-asserted global fixtures are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Hey @alinarublea,
Verdict: Request changes - OpenAPI spec is out of sync with the implemented route.
Changes: adds a new GET /config/:service/redirects.txt endpoint serving ASO redirect overlays from S3, now with per-tenant authz (site resolution + entitlement + enrollment check) and HMAC-normalized constant-time key validation (15 files).
Must fix before merge
- [Important] OpenAPI spec declares a path that does not exist in the router -
docs/openapi/api.yaml:309,docs/openapi/aso-overlay-api.yaml:18-25(details inline)
Non-blocking (1): minor issues and suggestions
- nit:
.env.example:82comment saysGET /config/<env>/<service>/redirects.txtbut the route has no<env>segment - update when fixing the spec.
Previously flagged, now resolved
safeEqualtiming side-channel: now HMAC-normalizes both inputs to fixed 32-byte digests beforetimingSafeEqual:envpath parameter unused: removed:envfrom the route entirely- Missing integration tests: full IT suite added (test/it/postgres/redirects.test.js + shared test factory)
- Auth failure logging added
- ANONYMOUS_ENDPOINTS regex tightened
- SERVICE_RE digit runs capped to
{1,10} - Test for
$metadata.httpStatusCode === 404fallback added - Access-control-util boundary test added
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 2m 27s | Cost: $5.38 | Commit: 01c69fda1f323fe6de073fbf66b718ba967bdef1
If this code review was useful, please react with 👍. Otherwise, react with 👎.
| description: Deployment environment segment (must match the serving deployment). | ||
| schema: | ||
| type: string | ||
| enum: [dev, stage, prod] |
There was a problem hiding this comment.
issue (blocking): The OpenAPI spec defines the path as /config/{env}/{service}/redirects.txt with an env path parameter (enum: [dev, stage, prod]), but the actual implementation route is GET /config/:service/redirects.txt - a single path parameter with no {env} segment.
This violates the repo CLAUDE.md rule: "Specification Sync: Keep OpenAPI specs and implementation in sync."
The auth handler regex (OVERLAY_ROUTE), controller params destructuring ({ service }), INTERNAL_ROUTES entry, and all integration tests confirm the route has no env segment. The spec is stale from before :env was removed.
Fix: Remove the env parameter from aso-overlay-api.yaml, update the path in api.yaml to /config/{service}/redirects.txt, and fix the .env.example comment. Run npm run docs:lint after.
Address MysticatBot review: the OpenAPI spec and .env.example still
declared the old GET /config/:env/:service/redirects.txt path after
:env was dropped from the router.
- api.yaml: path -> /config/{service}/redirects.txt
- aso-overlay-api.yaml: drop the env path parameter; document the
per-request authz (resolve service -> ASO-entitled site, 404 on miss)
- .env.example: fix the route comment (no <env> segment)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Hey @alinarublea,
Verdict: Approve - all prior findings addressed, clean on re-review.
Changes: adds a new GET /config/:service/redirects.txt endpoint serving ASO redirect overlays from S3, with per-tenant authz (site resolution + entitlement + enrollment) and HMAC-normalized constant-time key validation (15 files).
Previously flagged, now resolved
safeEqualtiming side-channel: now HMAC-normalizes both inputs to fixed 32-byte digests beforetimingSafeEqual:envpath parameter unused: removed from route entirely- Missing integration tests: full IT suite added (test/it/postgres/redirects.test.js + shared test factory)
- OpenAPI spec out of sync with route: synced in commit 7e7caed (path now
/config/{service}/redirects.txt, env parameter removed) - Auth failure logging added
- ANONYMOUS_ENDPOINTS regex tightened to match controller's capped digit pattern
- SERVICE_RE digit runs capped to realistic lengths
- Test for
$metadata.httpStatusCode === 404fallback added - Access-control-util boundary test added
.env.examplecomment updated to match the env-less route
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 17s | Cost: $6.50 | Commit: 7e7caed20f1f8de6a7fb0bae278157a9ce453d3a
If this code review was useful, please react with 👍. Otherwise, react with 👎.
Codecov patch coverage flagged 2 uncovered lines in aso-overlay-key-handler.js (the safeEqual non-string guard, unreachable via checkAuth but defensive per its "never throws" contract). Add a test driving a non-string header value through checkAuth, plus one for an empty pathInfo, bringing the handler to 100% statements/branches/lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Hey @alinarublea,
Verdict: Approve - all prior findings addressed, clean on re-review.
Changes: adds two defensive test cases to the ASO overlay key handler verifying graceful null returns on non-string header values and missing pathInfo fields (1 file).
Previously flagged, now resolved
safeEqualtiming side-channel: now HMAC-normalizes both inputs to fixed 32-byte digests beforetimingSafeEqual:envpath parameter unused: removed from route entirely- Missing integration tests: full IT suite added (test/it/postgres/redirects.test.js + shared test factory)
- OpenAPI spec out of sync with route: synced (path now
/config/{service}/redirects.txt, env parameter removed) - Auth failure logging added
- ANONYMOUS_ENDPOINTS regex tightened to match controller's capped digit pattern
- SERVICE_RE digit runs capped to realistic lengths
- Test for
$metadata.httpStatusCode === 404fallback added - Access-control-util boundary test added
.env.examplecomment updated to match the env-less route
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 0m 33s | Cost: $2.67 | Commit: 0a4623b70ac7a33f6824a7fa779afb810f04f573
If this code review was useful, please react with 👍. Otherwise, react with 👎.
|
🎉 This PR is included in version 1.589.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Implements Lite-E from the ASO dispatcher-overlay ADR (mysticat-architecture#159): move the redirect-overlay read path behind api-service instead of having Fastly sign SigV4 to S3 directly — with the per-request authorization that makes it safe (closes the cross-tenant enumeration hole rather than relocating it).
Endpoint
GET /config/:service/redirects.txt(e.g./config/cm-p154709-e1629980/redirects.txt)X-ASO-API-Keyvalidated byAsoOverlayKeyHandlerin theAUTH_HANDLERSchain (constant-time, length-safe HMAC compare; path-scoped early-bail like the GitHub webhook handler).cm-p<program>-e<env>→ resolve the site via the indexedSite.findByExternalOwnerIdAndExternalSiteId('p<program>','e<env>'); require the site's org to hold an ASO entitlement AND the site to be enrolled in it.config/<service>/redirects.txtfromS3_ASO_OVERLAYS_BUCKETusing the Lambda's own execution role.text/plain(200). Every authz miss → an indistinguishable404(no signal of which programs exist/are entitled).400for a malformed service id;500only for unexpected failures.Why this is the safe Lite-E
The shared
X-ASO-API-Keyalone would let any key-holder enumerate every program's overlay by walkingcm-pX-eY. The per-request entitlement check resolves only provisioned, ASO-entitled tenants — enumeration is gone, and authz is server-side in code we own and test. (Isolation ceiling is still Option A per the ADR; Full-E = swap the static key for a consumer TA, separate follow-up.)Notable details
:envdropped from the route — the bucket is already env-scoped via the Lambda's own deployment, so the segment was dead. Edge/dispatcher URL change to be coordinated separately with the dispatcher side.s3:ListBucket(least privilege, no S3-layer key enumeration), so a missing object surfaces as403 AccessDeniednot404. Mapped to404for the caller but logged at error so a missing IAM grant (spacecat-infrastructure#620) stays alertable.INTERNAL_ROUTES(excluded from S2S), authenticated by the key handler — no change to the global handler chain semantics beyond addingAsoOverlayKeyHandler.spacecat-role-lambda-generics3:GetObjectonspacecat-*-aso-overlays/config/*. Land it before this deploys per env./config/*origin from S3 to the api-service backend (Fastly-admin; supersedes the feat: limiting scrape to 100 urls per message #617 signing snippet) and drop:envfrom the dispatcher URL.S3_ASO_OVERLAYS_BUCKET+ASO_OVERLAY_API_KEYin Vault per env +npm run deploy-secrets.Supersedes spacecat-infrastructure#616 and #617 (no hand-rolled SigV4 in VCL, no long-lived static IAM keys to Fastly).
Tests
test/controllers/redirects.test.js— rewritten for the authz flow (200, 400, 404 for no-site / not-entitled / not-enrolled, NoSuchKey→404, AccessDenied→404, 500).test/support/aso-overlay-key-handler.test.js— the key handler.test/routes/index.test.js+auth-handlers-orderupdated.before()/after()so the count-asserted global fixtures are untouched.npm run lintclean; full unit suite green (12,245 passing; the one parallel-run timeout is a pre-existingplg-onboardinghook flake — passes 392/392 in isolation).🤖 Generated with Claude Code