Skip to content

Commit 16da0f1

Browse files
0.9: revert Lambda@Edge auth changes to dodge cross-region export deadlock
The auth Lambda hardening (timing-safe compare, Max-Age 30d, safeRedirectPath, security headers on 403) is sound, but ANY code change to the Lambda@Edge function publishes a new version, which forces the CDK CrossRegionExportWriter to update an SSM export that's still in use by GoodHabitTracker. The export update is rejected and the cert stack rolls back to UPDATE_ROLLBACK_FAILED. Recovered the cert stack with: aws cloudformation continue-update-rollback --stack-name GoodHabitTrackerCert --region us-east-1 --resources-to-skip ExportsWriteruswest209BD44F0A7CF058B All other v0.9 hardening (CloudFront response headers, /api/* query-forwarding off, 64KB body cap, generic errors, sprint numeric validation, escapeHtml on data-id, log retention, memorySize 256, DDB PITR, S3 versioning, backup scripts rewritten, UX/a11y) is unchanged and will ship on the next attempt. The auth Lambda work is documented as deferred to v0.10 - it needs a re-architecture of the cross-region reference (Lambda alias ARN with stable cross-region SSM key, or a 2-step deploy workflow that handles the in-use export gracefully). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fdf8609 commit 16da0f1

2 files changed

Lines changed: 19 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,27 @@ and `ddb-meta-20260519-122914.json` are the immediately-pre-wipe snapshots).
1515
### Security
1616

1717
- **CloudFront `ResponseHeadersPolicy` on the default behavior.** Adds `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`, a tight `Content-Security-Policy` (`default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`. The `/api/*` behavior uses the lighter AWS-managed `SECURITY_HEADERS` policy.
18-
- **Timing-safe cookie compare** in the Lambda@Edge auth function. Switched both `htok` and `unlock`-querystring hash comparisons from `===` to `crypto.timingSafeEqual` on equal-length hex buffers.
19-
- **`htok` cookie Max-Age dropped from 365 days to 30 days.** Bounds the impact of a leaked cookie; the user re-unlocks via bookmark or email URL after expiry.
20-
- **Open-redirect defense** on the unlock-success branch — `request.uri` passes through a `safeRedirectPath` helper that rejects anything not starting with a single `/`.
21-
- **Security headers on the 403 "private" auth response** (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`).
2218
- **CloudFront API origin: `QueryStringBehavior.none()`** (was `.all()`). No `/api/*` route reads query strings, so dropping them shrinks attack surface — an `unlock=` param can't accidentally reach the API origin if edge auth is ever bypassed.
2319
- **64 KiB request-body cap** in `getBody`. Returns 413 before any handler sees an oversized payload; previously the 6 MB Function URL ceiling was the only limit.
2420
- **Generic 500 error responses.** The catch-all in `index.js` no longer echoes SDK error messages (which could leak table names / ARNs / AWS error codes). Errors are `console.error`-logged server-side; the response body is `{ "error": "internal" }`.
2521
- **Server-side validation of sprint numeric fields.** New `safeLengthDays`, `safeGoalPoints`, `safePointStep` helpers in `sprints.js` clamp/validate `lengthDays` (1..365 integer), `goalPoints` (0..10000 finite), and `pointStep` (must be one of `[0.1, 0.25, 0.5, 1]`). Defends against `NaN`/non-numeric values from a buggy or hostile client.
2622
- **`Array.isArray` checks on `body.categories` and `body.habitDefinitions`** in both POST and PUT handlers — previously `body.categories || []` would have happily accepted a non-array value.
2723
- **`escapeHtml(id)` on every `data-id` attribute** in `plan-ui.js` and `entry-ui.js`. IDs come from `uid()` today so this is hygiene, but the layered defense protects against a future direct-API write of a crafted id.
2824

25+
### Deferred to v0.10 (Lambda@Edge auth hardening)
26+
27+
The security engineer's findings on the auth Lambda — **timing-safe cookie
28+
compare** (`crypto.timingSafeEqual`), **`htok` Max-Age dropped to 30 days**,
29+
**`safeRedirectPath` open-redirect guard**, **`nosniff`/`X-Frame-Options`/
30+
`Referrer-Policy` on the 403 response** — were implemented but **reverted before
31+
shipping** because any change to the Lambda@Edge code triggers the CDK
32+
cross-region SSM export deadlock (`ExportsWriteruswest209BD44F0A7CF058B` rejects
33+
the update because the main stack still imports the old version ARN). Shipping
34+
them safely requires either the documented 3-step `temp_drop_edge_auth` deploy
35+
(removed in v0.7 because the user disliked the auth gap) OR a re-architecture
36+
of the cross-region reference. Tracking as v0.10 work — the CloudFront default
37+
HSTS+CSP headers already cover the most important client-side guarantees.
38+
2939
### Cost / observability
3040

3141
- **`logRetention: ONE_MONTH` on the sync Lambda** (was infinite — CloudWatch storage was accruing forever).

infrastructure/lambdas/auth/index.js

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,6 @@ const crypto = require('node:crypto');
33
// Unlock hash is injected by CDK at deploy time (see infrastructure/lib/cert-stack.ts).
44
const UNLOCK_HASH = '__UNLOCK_HASH__';
55

6-
// Constant-time hex-string compare. We're comparing 64-char SHA-256 hex outputs,
7-
// so length is always equal and `crypto.timingSafeEqual` is well-defined.
8-
// Network jitter dwarfs any timing signal in practice, but this is the right
9-
// primitive and costs nothing.
10-
function safeEqualHex(a, b) {
11-
if (typeof a !== 'string' || typeof b !== 'string') return false;
12-
if (a.length !== b.length) return false;
13-
return crypto.timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
14-
}
15-
16-
// Reject anything that isn't a safe same-origin redirect target. CloudFront
17-
// normalises `request.uri`, but defense-in-depth: only accept paths starting
18-
// with a single `/`, no protocol-relative `//evil.com`.
19-
function safeRedirectPath(uri) {
20-
if (typeof uri !== 'string') return '/';
21-
if (!uri.startsWith('/')) return '/';
22-
if (uri.startsWith('//')) return '/';
23-
if (uri.startsWith('/\\')) return '/';
24-
return uri;
25-
}
26-
276
function getCookies(request) {
287
const raw = (request.headers.cookie || []).map((h) => h.value).join('; ');
298
const result = {};
@@ -66,16 +45,12 @@ function removeUnlockParam(querystring) {
6645
.join('&');
6746
}
6847

69-
// Cookie lifetime: 30 days (was 365). Shorter window bounds the impact of a
70-
// leaked cookie; the user re-unlocks via bookmark/email URL after expiry.
71-
const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
72-
7348
exports.handler = async (event) => {
7449
const request = event.Records[0].cf.request;
7550
const cookies = getCookies(request);
7651

77-
// Valid auth cookie — pass through (constant-time compare)
78-
if (safeEqualHex(cookies.htok, UNLOCK_HASH)) {
52+
// Valid auth cookie — pass through
53+
if (cookies.htok === UNLOCK_HASH) {
7954
if (request.querystring?.includes('unlock=')) {
8055
request.querystring = removeUnlockParam(request.querystring);
8156
}
@@ -86,9 +61,9 @@ exports.handler = async (event) => {
8661
const tok = getUnlockTokenRaw(request.querystring || '');
8762
if (tok) {
8863
const tokHash = crypto.createHash('sha256').update(tok).digest('hex');
89-
if (safeEqualHex(tokHash, UNLOCK_HASH)) {
64+
if (tokHash === UNLOCK_HASH) {
9065
const qs = removeUnlockParam(request.querystring || '');
91-
const dest = safeRedirectPath(request.uri) + (qs ? '?' + qs : '');
66+
const dest = request.uri + (qs ? '?' + qs : '');
9267
return {
9368
status: '302',
9469
statusDescription: 'Found',
@@ -97,12 +72,7 @@ exports.handler = async (event) => {
9772
'set-cookie': [
9873
{
9974
key: 'Set-Cookie',
100-
value:
101-
'htok=' +
102-
UNLOCK_HASH +
103-
'; Path=/; Max-Age=' +
104-
COOKIE_MAX_AGE_SECONDS +
105-
'; HttpOnly; Secure; SameSite=Lax',
75+
value: 'htok=' + UNLOCK_HASH + '; Path=/; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax',
10676
},
10777
],
10878
'cache-control': [{ key: 'Cache-Control', value: 'no-store, no-cache' }],
@@ -117,9 +87,6 @@ exports.handler = async (event) => {
11787
headers: {
11888
'content-type': [{ key: 'Content-Type', value: 'text/html; charset=utf-8' }],
11989
'cache-control': [{ key: 'Cache-Control', value: 'no-store, no-cache, private' }],
120-
'x-content-type-options': [{ key: 'X-Content-Type-Options', value: 'nosniff' }],
121-
'x-frame-options': [{ key: 'X-Frame-Options', value: 'DENY' }],
122-
'referrer-policy': [{ key: 'Referrer-Policy', value: 'no-referrer' }],
12390
},
12491
body: '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>private</title><style>body{background:#0a0a0b;color:#4a4a55;font-family:ui-monospace,monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-size:13px;letter-spacing:0.1em}</style></head><body>private</body></html>',
12592
};

0 commit comments

Comments
 (0)