Skip to content

feat(redirects): serve ASO redirect overlay via api-service (Lite-E)#2638

Merged
alinarublea merged 13 commits into
mainfrom
feat/aso-overlay-config-endpoint
Jun 22, 2026
Merged

feat(redirects): serve ASO redirect overlay via api-service (Lite-E)#2638
alinarublea merged 13 commits into
mainfrom
feat/aso-overlay-config-endpoint

Conversation

@alinarublea

@alinarublea alinarublea commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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)

  1. AuthN — inbound X-ASO-API-Key validated by AsoOverlayKeyHandler in the AUTH_HANDLERS chain (constant-time, length-safe HMAC compare; path-scoped early-bail like the GitHub webhook handler).
  2. AuthZ (per request) — parse cm-p<program>-e<env> → resolve the site via the indexed Site.findByExternalOwnerIdAndExternalSiteId('p<program>','e<env>'); require the site's org to hold an ASO entitlement AND the site to be enrolled in it.
  3. Reads config/<service>/redirects.txt from S3_ASO_OVERLAYS_BUCKET using the Lambda's own execution role.
  4. Returns text/plain (200). Every authz miss → an indistinguishable 404 (no signal of which programs exist/are entitled). 400 for a malformed service id; 500 only for unexpected failures.

Why this is the safe Lite-E

The shared X-ASO-API-Key alone would let any key-holder enumerate every program's overlay by walking cm-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

  • :env dropped 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.
  • 403 → 404 mapping: the reader role intentionally has no s3:ListBucket (least privilege, no S3-layer key enumeration), so a missing object surfaces as 403 AccessDenied not 404. Mapped to 404 for the caller but logged at error so a missing IAM grant (spacecat-infrastructure#620) stays alertable.
  • Route is in INTERNAL_ROUTES (excluded from S2S), authenticated by the key handler — no change to the global handler chain semantics beyond adding AsoOverlayKeyHandler.

⚠️ Required companion changes (not in this PR)

  1. Lambda IAM grantspacecat-infrastructure#620 grants spacecat-role-lambda-generic s3:GetObject on spacecat-*-aso-overlays/config/*. Land it before this deploys per env.
  2. Fastly — re-point the /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 :env from the dispatcher URL.
  3. SecretsS3_ASO_OVERLAYS_BUCKET + ASO_OVERLAY_API_KEY in 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-order updated.
  • Integration test is self-contained: seeds throwaway entitled sites + ASO enrollments via PostgREST in before()/after() so the count-asserted global fixtures are untouched.
  • npm run lint clean; full unit suite green (12,245 passing; the one parallel-run timeout is a pre-existing plg-onboarding hook flake — passes 392/392 in isolation).

🤖 Generated with Claude Code

alinarublea and others added 2 commits June 18, 2026 16:17
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

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@alinarublea alinarublea requested a review from MysticatBot June 18, 2026 14:26
@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

@MysticatBot MysticatBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. [Important] safeEqual leaks API key length via timing side-channel on length mismatch - src/controllers/redirects.js:43 (details inline)
  2. [Important] :env path 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 [^/]+ to cm-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 === 404 fallback branch (currently only err.name === 'NoSuchKey' is exercised) - test/controllers/redirects.test.js
  • suggestion: Add a test for the new anonymous endpoint regex in access-control-util.test.js to 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 👎.

Comment thread src/controllers/redirects.js Outdated
}
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. 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.
  2. Or, if the intent is that reqEnv should match the deployment env, assert reqEnv === env.ENVIRONMENT and return 400 on mismatch.
  3. Or, if the bucket is ever shared across envs, incorporate env into the key: config/${reqEnv}/${service}/redirects.txt

@MysticatBot MysticatBot added the ai-reviewed Reviewed by AI label Jun 18, 2026
alinarublea and others added 2 commits June 18, 2026 16:49
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>

@MysticatBot MysticatBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. [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's SERVICE_RE uses \d{1,10} - align for defense-in-depth consistency - src/support/access-control-util.js:30

Previously flagged, now resolved

  • safeEqual timing side-channel: now HMAC-normalizes both inputs to fixed 32-byte digests before timingSafeEqual
  • :env path 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 [^/]+ to cm-p\d+-e\d+
  • SERVICE_RE digit runs capped to {1,10}
  • Test for $metadata.httpStatusCode === 404 fallback 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 @@
/*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/support/access-control-util.js Outdated
/^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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@alinarublea alinarublea requested a review from MysticatBot June 22, 2026 09:10

@MysticatBot MysticatBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. [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:82 comment says GET /config/<env>/<service>/redirects.txt but the route has no <env> segment - update when fixing the spec.

Previously flagged, now resolved

  • safeEqual timing side-channel: now HMAC-normalizes both inputs to fixed 32-byte digests before timingSafeEqual
  • :env path parameter unused: removed :env from 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 === 404 fallback 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 👎.

Comment thread docs/openapi/aso-overlay-api.yaml Outdated
description: Deployment environment segment (must match the serving deployment).
schema:
type: string
enum: [dev, stage, prod]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@MysticatBot MysticatBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • safeEqual timing side-channel: now HMAC-normalizes both inputs to fixed 32-byte digests before timingSafeEqual
  • :env path 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 === 404 fallback added
  • Access-control-util boundary test added
  • .env.example comment 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>
@alinarublea alinarublea requested a review from MysticatBot June 22, 2026 10:19

@MysticatBot MysticatBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • safeEqual timing side-channel: now HMAC-normalizes both inputs to fixed 32-byte digests before timingSafeEqual
  • :env path 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 === 404 fallback added
  • Access-control-util boundary test added
  • .env.example comment 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 👎.

@alinarublea alinarublea merged commit a900c15 into main Jun 22, 2026
18 checks passed
@alinarublea alinarublea deleted the feat/aso-overlay-config-endpoint branch June 22, 2026 14:36
solaris007 pushed a commit that referenced this pull request Jun 22, 2026
# [1.589.0](v1.588.0...v1.589.0) (2026-06-22)

### Features

* **redirects:** serve ASO redirect overlay via api-service (Lite-E) ([#2638](#2638)) ([a900c15](a900c15)), closes [#617](#617)
@solaris007

Copy link
Copy Markdown
Member

🎉 This PR is included in version 1.589.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants