feat(PRO-402): edge contract 2 — Function decides, Lambda serves /__mcl/* - #14
Merged
Merged
Conversation
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>
# Conflicts: # package-lock.json
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rewrites the plugin onto the assess/enforce model. Pairs with the dashboard side on
feat/pro-402inspurintel/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/challengeor/__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 appendingcpdthemselves; the challenge page reads its return path client-side.Worth a closer look
The distribution's own
*.cloudfront.netaddress 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 fromevent.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 verifyOrigincheck.A store missing
idorcvpasses 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 carryx-monocle-skip: no-configto the origin, set after the viewer's ownx-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/statereturns 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
stripForDeployso what is tested is what deploys.Note for review
mainhas moved by one dependency commit (1fc38a9, vitest 4.1.11) which touchespackage.json/package-lock.json— likely a small conflict to resolve before merge.🤖 Generated with Claude Code