Skip to content

feat(PRO-402): edge contract 2 — Function decides, Lambda serves /__mcl/* - #14

Merged
alistairsmith-tech merged 6 commits into
mainfrom
feat/pro-402
Sep 18, 2026
Merged

alistairsmith-tech merged 6 commits into
mainfrom
feat/pro-402

Conversation

@alistairsmith-tech

Copy link
Copy Markdown
Collaborator

Rewrites the plugin onto the assess/enforce model. Pairs with the dashboard side on feat/pro-402 in spurintel/web, under PRO-402.

Shape

Viewer-request Function (8,442 of 10,240 bytes stripped): runs the decide() ladder against KVS facts — host check, strict path canonicalisation, HMAC v2 verdict cookie with clearance version and IPv6 /64 binding, shape detection, crawler and allow-list passes, breaker. Clean requests pass to cache; refusals are a ~300-byte shell that navigates to /__mcl/challenge or /__mcl/blocked.

Lambda@Edge on /__mcl/*, origin-request: state, verify, challenge, blocked, resubmit and the resident script. Origin-request is load-bearing, not a preference — a viewer-request trigger is capped at 5 seconds, which the Policy call alone budgets, and only an origin response can be cached.

Caching: the resident script and the challenge page are identical for every visitor, so they sit on CachingOptimized. The session id therefore reaches the assessment through GET /__mcl/state, with the scripts appending cpd themselves; the challenge page reads its return path client-side.

Worth a closer look

The distribution's own *.cloudfront.net address is now protected. It is the same site by its other door, and the only hostname a visitor can choose to reach this deployment — passing it through left any alias deployment bypassable by whoever looked up the domain. Read from event.context.distributionDomainName, so no config, no KVS key, and correct after a redeploy elsewhere. A deployment still binds one configured hostname; the Lambda already trusted this same value for the verify Origin check.

A store missing id or cv passes traffic, marked. Neither can validate a cookie, so no challenge could ever be passed — refusing would hit every visitor rather than the ones the policy flags, on assessed and enforced routes alike. Those requests carry x-monocle-skip: no-config to the origin, set after the viewer's own x-monocle-* headers are stripped so it cannot be forged. The fix for that state is the missing keys; the dashboard health check now reports it directly.

/__mcl/state returns the IP the Lambda evaluates against and binds the cookie to. One request now answers "what does the edge think my address is", which took a long detour to establish by inference.

Testing

57 tests, including the stripped-size gate. The Function is exercised through stripForDeploy so what is tested is what deploys.

Note for review

main has moved by one dependency commit (1fc38a9, vitest 4.1.11) which touches package.json/package-lock.json — likely a small conflict to resolve before merge.

🤖 Generated with Claude Code

alistairsmith-tech and others added 6 commits September 1, 2026 16:13
Ports the deny-shape §2 hardening from PRO-400. CloudFront had the IP-unbound
cookie bug in TWO places, because the Function mirrors the Lambda's verifier.

- Never mint an IP-unbound cookie. buildSetCookie minted with an EMPTY ip field
  when no client IP was available, and BOTH verifiers then SKIPPED the IP
  comparison for any cookie whose stored IP was empty (shared/cookies.ts:59 and
  function/index.js:134), producing a portable bearer token valid from any
  address for its full lifetime. buildSetCookie now returns null and mints
  nothing, and neither verifier exempts an empty stored IP, so a cookie from an
  older release cannot be replayed either.

  The existing test asserted the old behaviour by name ("skips the IP check for
  an IP-unbound cookie"), so it encoded the bug. Replaced with one asserting
  nothing is minted, plus a defence-in-depth case for a hand-signed empty-IP
  payload.

- Never mint on fail-open. A degraded Policy API returned allowResponse, issuing
  a valid 1h cookie, so posting junk to /__mcl/verify during a brownout yielded
  a real credential. Fail-open now returns without minting. A 404 still mints:
  that is a real API answer meaning no policy is configured. The existing
  non-transient-4xx deny path is unchanged.

- Enforce assessment freshness. isAssessmentFresh added to shared/policy.ts so
  the Lambda checks age as well as the allow verdict. A missing timestamp is
  logged and allowed rather than denied, matching PRO-400 and PRO-401: the client
  cannot influence whether the API returns it, and denying would take every
  customer down at once if it were ever absent. An unparseable timestamp, which
  a client CAN supply, still fails closed.

- Minting failure no longer reports success: allowResponse returns a retryable
  503 rather than a 200 with no cookie, which would loop the visitor.

IPv6 /64 binding is deliberately NOT included here. The Function has a 10KB code
budget with roughly 440 bytes free, and a correct IPv6 expander does not fit.
IPv4 and IPv6 both stay exact, which is unchanged behaviour rather than a
regression, so mobile IPv6 visitors still re-challenge on address rotation. It
lands with the Plan A work on this ticket, where the interstitial is trimmed and
frees the necessary bytes.

Removing the empty-IP clause made the Function 6 bytes SMALLER: 9799 -> 9793.
58 tests pass, build clean and within budget.

Refs PRO-402
…cl/*

Rewrites the plugin onto the assess/enforce model. The viewer-request
Function runs edge-core's decide() ladder against KVS facts and passes clean
requests to cache; the Lambda@Edge moves to origin-request on /__mcl/* and
serves state, verify, challenge, blocked and the resident script. Origin
request is not a preference: a viewer-request trigger is capped at 5 seconds,
which the Policy call alone budgets, and only an origin response can be
cached.

The resident script and the challenge page are cacheable and identical for
every visitor, so the session id reaches the assessment through
GET /__mcl/state and the scripts append cpd themselves. The challenge page
reads its return path client-side, and drops the refusal set's Vary: it is a
public object keyed by URL alone.

The Function also acts on the distribution's own *.cloudfront.net address,
read from the request context rather than the host list. It is the same site
by its other door - the one hostname a visitor can choose to reach this
deployment - and passing it through left an alias deployment bypassable by
anyone who knew the domain. A deployment still protects one configured
hostname; the Lambda already trusted the same value for the verify Origin
check.

A store without id or cv can validate no cookie, so no challenge could ever
be passed and refusing would hit every visitor rather than the ones the
policy flags. Those requests pass, marked x-monocle-skip: no-config, on
assessed and enforced routes alike - the fix is the missing keys, never a
challenge loop. The marker is set after the viewer's own x-monocle-* headers
are stripped, so it cannot be forged.

/__mcl/state reports the IP the Lambda evaluates against and binds the cookie
to, so a deployment whose verdicts look wrong can be checked with one request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A distribution is a site, so the hostnames it answers on are that site's names:
an apex, a www name, a short domain, the *.cloudfront.net address, and whatever
gets added next year. Naming one of them protected that one and left the rest as
ways around Monocle - and an allowlist is always a bypass list for whatever is
not on it.

An empty `hosts` now means every hostname the distribution serves. An absent
`hosts` still fails open, as a store we cannot use always does: the two are
distinguishable, so a broken store is never mistaken for a deployment choosing
everything.

Verify's Origin check has no list to match in that case, so it falls back to the
browser's own statement that this is its page calling its own origin. Cross-site
and same-site were already refused; a caller forging the header buys only a
cookie bound to its own IP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Access role: grant only the API calls the integration makes. Ten actions were
granted and never called (cloudfront:GetFunction, ListKeyValueStores,
UpdateKeyValueStore, the KVS Get/Put/DeleteKey trio, iam:GetRolePolicy,
lambda:GetPolicy, scheduler:GetSchedule and ListSchedules). AWS's dependent-action
table names none of them as implied by a write we do make; the only dependency in
the kept set is iam:PassRole, already granted and scoped to monocle-*. Adds
iam:UpdateAssumeRolePolicy, which a redeploy needs to repair a drifted trust policy.

Cookie rotation: the previous-key path was only ever half built - the edge read
`kp` and accepted a second key, and nothing has ever written one. Removing it
takes a per-request KVS lookup off the hot path (function 8472 -> 8379 bytes).

Also drops exports nothing imported, takes the resident-script version from
edge-core, and trims comments that argued for a decision rather than describing
the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
npm ci fetches @spur.us/monocle-edge-core over ssh and the runner has no key,
so every run has failed at install since the dependency was added. Mirrors the
web repo: a read-only deploy key loaded into ssh-agent against a pinned
github.com host key.

Node moves to 22 in the same pass - edge-core declares engines
^22.12.0 || >=24.0.0, which node 20 reported as EBADENGINE on every install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alistairsmith-tech
alistairsmith-tech merged commit 0b3a4b0 into main Sep 18, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant