@everystack/mcp 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/caching.md +78 -0
- package/dist/deployment.md +3 -0
- package/dist/expo-server-deploy.md +11 -2
- package/dist/index.cjs +11 -1
- package/dist/security.md +2 -2
- package/package.json +4 -3
- package/src/prompts/debug.ts +2 -0
- package/src/prompts/deploy.ts +1 -0
- package/src/prompts/secure.ts +1 -0
- package/src/resources/caching.md +78 -0
- package/src/resources/deployment.md +3 -0
- package/src/resources/expo-server-deploy.md +11 -2
- package/src/resources/index.ts +7 -0
- package/src/resources/security.md +2 -2
package/dist/caching.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Caching & WAF (CloudFront Edge)
|
|
2
|
+
|
|
3
|
+
> The edge pipeline: WAF filters malicious traffic, edge auth verifies JWTs, CloudFront caches public GETs and SSR HTML, the handler sets `Cache-Control` by auth state, and mutations bump epoch keys that invalidate stale entries. Read the WAF section BEFORE enabling managed rules — the default managed rules 403 every PostgREST/JSON API request without the shipped exclusions.
|
|
4
|
+
|
|
5
|
+
## THE TRAP — AWS managed rules 403 your JSON API
|
|
6
|
+
|
|
7
|
+
AWS WAF managed rules inspect the **request body and headers**, not just the URL. The moment you enable them on a PostgREST/JSON API, several rules fire on legitimate traffic and return **403 before the request ever reaches Lambda**:
|
|
8
|
+
|
|
9
|
+
- `AWSManagedRulesSQLiRuleSet` → **`SQLi_BODY`**: PostgREST JSON keys (`limit`, `select`, `order`, `sort`) read as SQL keywords. Every `POST`/RPC and every `select()` with those keys 403s.
|
|
10
|
+
- `AWSManagedRulesCommonRuleSet` → **`SizeRestrictions_BODY`** (large JSON payloads) and **`GenericRFI_BODY`** (URLs inside JSON values look like remote-file-inclusion).
|
|
11
|
+
- `AWSManagedRulesKnownBadInputsRuleSet` → **`Log4JRCE_HEADER`**: the JWT in the `Authorization` header trips the Log4Shell signature.
|
|
12
|
+
|
|
13
|
+
These are false positives — Drizzle parameterizes every query server-side, so none of it is real injection. But WAF has no application context and blocks anyway.
|
|
14
|
+
|
|
15
|
+
> Do NOT "exclude `SQLi_QUERYARGUMENTS`." That rule lives in `AWSManagedRulesSQLiRuleSet`, and a `ruleActionOverride` naming a rule that isn't in the group you attach it to **silently no-ops** — the exclusion looks configured while the BODY rules keep 403-ing. Exclude the BODY/HEADER rules named above.
|
|
16
|
+
|
|
17
|
+
### The fix — two composed transforms
|
|
18
|
+
|
|
19
|
+
everystack ships the exclusion set and a rate-limit-429 transform in `@everystack/server/cdn`. Set the false-positive rules to **COUNT** mode (they still log to CloudWatch `AWS/WAFV2`, they just stop blocking) and answer rate limits with **429** instead of 403:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { applyWafExclusions, applyWafRateLimit429 } from '@everystack/server/cdn';
|
|
23
|
+
|
|
24
|
+
// MUST be registered BEFORE `new sst.aws.Router(...)`. A $transform only applies to
|
|
25
|
+
// resources created AFTER it, and the Router is what creates the WebAcl — register it
|
|
26
|
+
// after the Router and it silently no-ops.
|
|
27
|
+
$transform(aws.wafv2.WebAcl, (args: any) => {
|
|
28
|
+
if (!args.rules) return;
|
|
29
|
+
args.rules = $resolve(args.rules).apply(
|
|
30
|
+
(rules: any[]) => applyWafRateLimit429(applyWafExclusions(rules)),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const router = new sst.aws.Router('Router', {
|
|
35
|
+
// ...
|
|
36
|
+
waf: {
|
|
37
|
+
rateLimitPerIp: 2000,
|
|
38
|
+
managedRules: { common: true, sqli: true, knownBadInputs: true },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **COUNT ≠ disabled.** The rule still evaluates and records to CloudWatch — you keep visibility, you just stop the false-positive block. Nothing is turned off.
|
|
44
|
+
- **`applyWafExclusions(rules, overrides?)`** defaults to the shipped `WAF_JSON_API_OVERRIDES` map. Pass a second argument (rule group → rule names) to extend it if you hit a new false positive.
|
|
45
|
+
- **`applyWafRateLimit429`** rewrites rate-based block actions to `429 + Retry-After`. This is SEO-load-bearing, not cosmetic: Google reads any 4xx except 429 as "gone" and deindexes the URL over days; 429 means "back off and retry." A rate-limited public SSR site returning 403 becomes a deindexing engine.
|
|
46
|
+
|
|
47
|
+
### GET-filter false positives (diagnosing new ones)
|
|
48
|
+
|
|
49
|
+
PostgREST GET syntax (`?title=eq.hello`, `?or=(status.eq.draft,status.eq.review)`) can trip query-argument SQLi signatures that are NOT in the default map. Don't guess — turn on `waf.logging`, read the WAF **sampled requests** in the console to find the exact rule that matched, then add it via the second argument to `applyWafExclusions`. Set the offending rule to COUNT, confirm the request passes, and keep watching the count metric.
|
|
50
|
+
|
|
51
|
+
## WAF configuration
|
|
52
|
+
|
|
53
|
+
`sst.aws.Router`'s `waf` block configures rate limiting and managed rule sets:
|
|
54
|
+
|
|
55
|
+
| Field | Purpose |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `rateLimitPerIp` | Per-IP request cap over a 5-minute sliding window. On exceed, WAF 403s (429 with the transform above) until the window resets. `2000` (~6.7 req/s sustained) suits most APIs. |
|
|
58
|
+
| `managedRules.common` | `AWSManagedRulesCommonRuleSet` — broad OWASP-style protections. |
|
|
59
|
+
| `managedRules.sqli` | `AWSManagedRulesSQLiRuleSet` — SQL-injection signatures. |
|
|
60
|
+
| `managedRules.knownBadInputs` | `AWSManagedRulesKnownBadInputsRuleSet` — known exploit payloads (Log4Shell, etc.). |
|
|
61
|
+
|
|
62
|
+
All three managed sets are AWS Free-Tier rules (~$1/rule-group/month + per-request); no per-request cost beyond base WAF pricing.
|
|
63
|
+
|
|
64
|
+
**What WAF does NOT cover** (it sees IPs and HTTP, not application state):
|
|
65
|
+
- Per-account auth brute force — that belongs in `@everystack/auth` (account lockout, progressive delays). WAF rate limiting is per-IP.
|
|
66
|
+
- Bot quality — AWS Bot Control (Targeted) can fingerprint sophisticated bots but costs $10/M requests; managed rules + rate limiting suffice for most apps.
|
|
67
|
+
|
|
68
|
+
## The rest of the edge pipeline
|
|
69
|
+
|
|
70
|
+
**Edge auth.** For latency-sensitive authenticated paths, a CloudFront function verifies the JWT signature at the edge before the request reaches Lambda. See `everystack://auth` for `authFeature` / `composeViewerRequest` and edge token lifetimes (keep edge access tokens short, ~15 min).
|
|
71
|
+
|
|
72
|
+
**Response caching (Cache-Control by auth state).** The handler sets headers based on authentication:
|
|
73
|
+
- Public GETs and SSR HTML → cacheable at the edge (`public, max-age=...`). SSR HTML is the same for every visitor (good for SEO); user-specific content loads on the client during rehydration.
|
|
74
|
+
- Authenticated API responses → `private, no-store`, bypassing the CDN cache entirely.
|
|
75
|
+
|
|
76
|
+
**Epoch invalidation.** Mutations bump an epoch-based version key (in the CloudFront KVS), which naturally invalidates stale public cache entries — no manual purging. Reads carry the current epoch; a write advances it so subsequent reads miss the old cached copy.
|
|
77
|
+
|
|
78
|
+
**Debugging an edge 403.** A 403 with **no corresponding Lambda log entry** was blocked at the edge (WAF or edge auth), not by your handler. Check the WAF sampled requests for the matched rule, confirm with `curl -sI` against the API path, and reach for the exclusions transform above. A 403 that DOES reach Lambda is an application authz decision, not WAF.
|
package/dist/deployment.md
CHANGED
|
@@ -59,6 +59,9 @@ export default $config({
|
|
|
59
59
|
const router = new sst.aws.Router('Router', {
|
|
60
60
|
routes: { '/*': api.url },
|
|
61
61
|
});
|
|
62
|
+
// Adding WAF (`waf: { managedRules, rateLimitPerIp }`)? Read everystack://caching
|
|
63
|
+
// FIRST — AWS managed rules 403 every PostgREST request without the shipped
|
|
64
|
+
// exclusions transform, which must register BEFORE this Router.
|
|
62
65
|
|
|
63
66
|
// CLI needs these outputs
|
|
64
67
|
return {
|
|
@@ -92,6 +92,7 @@ Your OTHER `+api.ts` routes (`app/mcp+api.ts`, `app/games/[id]+api.ts`, ...) nee
|
|
|
92
92
|
|
|
93
93
|
```typescript
|
|
94
94
|
// server/ops.ts
|
|
95
|
+
import { Resource } from 'sst';
|
|
95
96
|
import { createPluginLambdaHandler, dbPlugin } from '@everystack/server/plugin';
|
|
96
97
|
import { createAppContext, appPlugins } from './context';
|
|
97
98
|
|
|
@@ -104,6 +105,10 @@ export const handler = createPluginLambdaHandler({
|
|
|
104
105
|
migrationsFolder: 'drizzle',
|
|
105
106
|
seed: async (db, schema) => (await import('../db/seed.js')).runSeed(db, schema),
|
|
106
107
|
// ^ lazy import — a top-level import would run at Lambda INIT and crash the boot
|
|
108
|
+
backupBucket: Resource.Backups?.name,
|
|
109
|
+
// ^ registers db:backup / db:backups / db:restore. Omit it and those actions
|
|
110
|
+
// return "backup storage not configured"; omit the whole ops Lambda and the
|
|
111
|
+
// CLI can't reach them at all — it reports "Unknown action" (see Gotchas).
|
|
107
112
|
}),
|
|
108
113
|
],
|
|
109
114
|
});
|
|
@@ -133,6 +138,7 @@ export default $config({
|
|
|
133
138
|
|
|
134
139
|
const updates = new sst.aws.Bucket('Updates'); // published Expo exports (server bundles)
|
|
135
140
|
const clientBundles = new sst.aws.Bucket('ClientBundles', { access: 'cloudfront' });
|
|
141
|
+
const backups = new sst.aws.Bucket('Backups'); // private — logical pg_dump backups (db:backup)
|
|
136
142
|
|
|
137
143
|
const jwtSecret = new sst.Secret('JwtSecret');
|
|
138
144
|
// Least-privilege API credential — written by `everystack db:provision`.
|
|
@@ -162,17 +168,19 @@ export default $config({
|
|
|
162
168
|
runtime: 'nodejs20.x',
|
|
163
169
|
timeout: '120 seconds',
|
|
164
170
|
vpc,
|
|
165
|
-
link: [database, databaseUrl, updates, clientBundles, jwtSecret],
|
|
171
|
+
link: [database, databaseUrl, updates, clientBundles, jwtSecret, backups],
|
|
166
172
|
copyFiles: [{ from: 'drizzle', to: 'drizzle' }], // migrations must ship with the ops bundle
|
|
167
173
|
});
|
|
168
174
|
|
|
169
|
-
// The CLI discovers resources through these outputs
|
|
175
|
+
// The CLI discovers resources through these outputs. The first five are required;
|
|
176
|
+
// backupsBucket unlocks db:backup / db:swap / db:fork (also auto-discovered by bucket name).
|
|
170
177
|
return {
|
|
171
178
|
routerUrl: router.url,
|
|
172
179
|
apiFunctionName: api.name,
|
|
173
180
|
opsFunctionName: ops.name,
|
|
174
181
|
updatesBucket: updates.name,
|
|
175
182
|
clientBundlesBucket: clientBundles.name,
|
|
183
|
+
backupsBucket: backups.name,
|
|
176
184
|
};
|
|
177
185
|
},
|
|
178
186
|
});
|
|
@@ -205,4 +213,5 @@ Subsequent app-code ships are `everystack update` alone — no `sst deploy`, no
|
|
|
205
213
|
- **A path claimed by a plugin never reaches your bundle's route.** If you mount the everystack handler at `/api`, a bundle route at `app/api/foo+api.ts` is shadowed in production (it still serves in local dev unless the catch-all shadows it there too — keep the surfaces aligned).
|
|
206
214
|
- **Lazy-load your seed** (shown above). A top-level `import { runSeed }` in an entrypoint runs at Lambda INIT, tries to reach the database before configuration, and kills the boot.
|
|
207
215
|
- **`copyFiles` the migrations folder** into the ops function, or `db:migrate` fails with ENOENT.
|
|
216
|
+
- **`db:backup`/`db:export`/`db:restore` run in the ephemeral Fargate Task lane — wire `dbTaskPlugin({ backupBucket })` on the ops function.** They are pg_dump/pg_restore work; the ops Lambda holds no pg binaries. `everystack db:backup` reporting `Unknown action: db:backup` means the CLI reached a function with no `dbTaskPlugin` mounted (usually: no ops function at all, so `opsFunctionName` fell back to the Api Lambda). Deploy the `Ops` function above with `dbTaskPlugin`. If instead it reports `backup storage not configured`, `dbTaskPlugin` is mounted but `backupBucket` is unset — pass `() => Resource.Backups.name`. The Task image's `pg_dump`-vs-server compatibility is verified on demand by `everystack task:probe` (there is no Lambda pg_dump layer).
|
|
208
217
|
- **`+api.ts` routes run with the bundle's environment**, not per-route SST links. Resource-linked work (secrets, buckets) belongs in plugins on the Lambda side; keep bundle routes to app logic over the request.
|
package/dist/index.cjs
CHANGED
|
@@ -21652,6 +21652,12 @@ var RESOURCES = [
|
|
|
21652
21652
|
description: "SST deployment: sst.config.ts setup, secrets, stages (dev/production), CloudFront, Lambda configuration, VPC, RDS Aurora Serverless, resource linking.",
|
|
21653
21653
|
filename: "deployment.md"
|
|
21654
21654
|
},
|
|
21655
|
+
{
|
|
21656
|
+
uri: "everystack://caching",
|
|
21657
|
+
name: "Caching & WAF (CloudFront Edge)",
|
|
21658
|
+
description: "READ BEFORE enabling WAF on the Router. AWS managed rules 403 every PostgREST/JSON API request without the shipped exclusions transform (applyWafExclusions/applyWafRateLimit429 from @everystack/server/cdn). Also: edge auth, response caching by auth state, epoch invalidation, and debugging edge 403s.",
|
|
21659
|
+
filename: "caching.md"
|
|
21660
|
+
},
|
|
21655
21661
|
{
|
|
21656
21662
|
uri: "everystack://expo-server-deploy",
|
|
21657
21663
|
name: "Deploying an Expo Router Server App",
|
|
@@ -22478,6 +22484,7 @@ function registerDeployPrompt(server) {
|
|
|
22478
22484
|
"1. Read everystack://deployment for infrastructure setup.",
|
|
22479
22485
|
"2. Read everystack://security for AWS credential setup and deployment checklist.",
|
|
22480
22486
|
"3. Read everystack://database-operations for the SAFE schema-migration flow (db:plan \u2192 db:apply, snapshot-first) \u2014 a protected stage is NOT migrated with db:migrate.",
|
|
22487
|
+
" If the deploy enables WAF (`waf` on the Router), read everystack://caching first \u2014 the managed rules 403 every PostgREST request without the shipped exclusions transform, which must register BEFORE the Router.",
|
|
22481
22488
|
projectPath ? `4. Run check_environment with projectPath="${projectPath}" to confirm prerequisites (Node, SST, AWS credentials) before deploying.` : "",
|
|
22482
22489
|
"",
|
|
22483
22490
|
"## Pre-Deploy Checklist",
|
|
@@ -22619,6 +22626,8 @@ function registerDebugPrompt(server) {
|
|
|
22619
22626
|
"",
|
|
22620
22627
|
"If the symptom involves authentication or authorization:",
|
|
22621
22628
|
"",
|
|
22629
|
+
"- **First, edge vs handler:** a 403 with NO matching Lambda log entry was blocked at the edge (WAF or edge auth), not by your handler. This is common right after enabling `waf` managed rules \u2014 the AWS SQLi/body rules 403 every PostgREST request. Check the WAF sampled requests and read everystack://caching for the exclusions transform. A 403 that DOES reach Lambda is an application authz decision \u2014 continue below.",
|
|
22630
|
+
"",
|
|
22622
22631
|
"- **Check token flow:**",
|
|
22623
22632
|
" - Is the JWT being sent in the Authorization header?",
|
|
22624
22633
|
" - Is the token expired? Decode at jwt.io and check `exp`",
|
|
@@ -22922,6 +22931,7 @@ function registerSecurePrompt(server) {
|
|
|
22922
22931
|
"- Rejects expired or malformed tokens at the edge",
|
|
22923
22932
|
"- Reduces Lambda invocations for unauthorized requests",
|
|
22924
22933
|
"- See everystack://deployment for CloudFront function setup",
|
|
22934
|
+
"- WAF at the edge: everystack://caching \u2014 enabling AWS managed rules without the shipped exclusions 403s every PostgREST request",
|
|
22925
22935
|
""
|
|
22926
22936
|
].join("\n") : "",
|
|
22927
22937
|
"## Verification",
|
|
@@ -23723,7 +23733,7 @@ async function runGovernanceCli(argv) {
|
|
|
23723
23733
|
}
|
|
23724
23734
|
|
|
23725
23735
|
// src/index.ts
|
|
23726
|
-
var version2 = (true ? "0.4.
|
|
23736
|
+
var version2 = (true ? "0.4.2" : null) ?? "0.3.0-dev";
|
|
23727
23737
|
var INSTRUCTIONS = [
|
|
23728
23738
|
"You govern how any agent builds everystack \u2014 a self-hosted application stack for Expo apps on AWS.",
|
|
23729
23739
|
"Your job is not only to advise but to keep the build on-script: the architecture the maintainer",
|
package/dist/security.md
CHANGED
|
@@ -263,8 +263,8 @@ RESET ROLE;
|
|
|
263
263
|
- [ ] Bcrypt cost >= 10
|
|
264
264
|
|
|
265
265
|
### Infrastructure
|
|
266
|
-
- [ ] WAF managed rules
|
|
267
|
-
- [ ] Rate limiting (
|
|
266
|
+
- [ ] WAF managed rules — set the JSON-API false-positive BODY/HEADER rules (SQLi_BODY, SizeRestrictions_BODY, GenericRFI_BODY, Log4JRCE_HEADER) to COUNT via `applyWafExclusions`, or they 403 every PostgREST request. See everystack://caching (read BEFORE enabling `waf` on the Router).
|
|
267
|
+
- [ ] Rate limiting — 429 not 403 (applyWafRateLimit429); ~2000 req/5 min per IP. See everystack://caching.
|
|
268
268
|
- [ ] CloudFront edge JWT verification for latency-sensitive paths
|
|
269
269
|
- [ ] Database credentials via SST Resource linking
|
|
270
270
|
- [ ] No debug endpoints or env dumps in production
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Governance layer that governs how any agent builds everystack — grounding, cheat gates, and Model-aware tooling over MCP",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -40,8 +40,9 @@
|
|
|
40
40
|
"tsx": "4.21.0",
|
|
41
41
|
"typescript": "5.9.3",
|
|
42
42
|
"zod": "3.25.67",
|
|
43
|
-
"@everystack/cli": "0.4.
|
|
44
|
-
"@everystack/model": "0.4.
|
|
43
|
+
"@everystack/cli": "0.4.32",
|
|
44
|
+
"@everystack/model": "0.4.5",
|
|
45
|
+
"@everystack/server": "0.4.14"
|
|
45
46
|
},
|
|
46
47
|
"scripts": {
|
|
47
48
|
"test": "jest",
|
package/src/prompts/debug.ts
CHANGED
|
@@ -33,6 +33,8 @@ export function registerDebugPrompt(server: McpServer): void {
|
|
|
33
33
|
'',
|
|
34
34
|
'If the symptom involves authentication or authorization:',
|
|
35
35
|
'',
|
|
36
|
+
'- **First, edge vs handler:** a 403 with NO matching Lambda log entry was blocked at the edge (WAF or edge auth), not by your handler. This is common right after enabling `waf` managed rules — the AWS SQLi/body rules 403 every PostgREST request. Check the WAF sampled requests and read everystack://caching for the exclusions transform. A 403 that DOES reach Lambda is an application authz decision — continue below.',
|
|
37
|
+
'',
|
|
36
38
|
'- **Check token flow:**',
|
|
37
39
|
' - Is the JWT being sent in the Authorization header?',
|
|
38
40
|
' - Is the token expired? Decode at jwt.io and check `exp`',
|
package/src/prompts/deploy.ts
CHANGED
|
@@ -26,6 +26,7 @@ export function registerDeployPrompt(server: McpServer): void {
|
|
|
26
26
|
'1. Read everystack://deployment for infrastructure setup.',
|
|
27
27
|
'2. Read everystack://security for AWS credential setup and deployment checklist.',
|
|
28
28
|
'3. Read everystack://database-operations for the SAFE schema-migration flow (db:plan → db:apply, snapshot-first) — a protected stage is NOT migrated with db:migrate.',
|
|
29
|
+
' If the deploy enables WAF (`waf` on the Router), read everystack://caching first — the managed rules 403 every PostgREST request without the shipped exclusions transform, which must register BEFORE the Router.',
|
|
29
30
|
projectPath ? `4. Run check_environment with projectPath="${projectPath}" to confirm prerequisites (Node, SST, AWS credentials) before deploying.` : '',
|
|
30
31
|
'',
|
|
31
32
|
'## Pre-Deploy Checklist',
|
package/src/prompts/secure.ts
CHANGED
|
@@ -208,6 +208,7 @@ export function registerSecurePrompt(server: McpServer): void {
|
|
|
208
208
|
'- Rejects expired or malformed tokens at the edge',
|
|
209
209
|
'- Reduces Lambda invocations for unauthorized requests',
|
|
210
210
|
'- See everystack://deployment for CloudFront function setup',
|
|
211
|
+
'- WAF at the edge: everystack://caching — enabling AWS managed rules without the shipped exclusions 403s every PostgREST request',
|
|
211
212
|
'',
|
|
212
213
|
].join('\n') : '',
|
|
213
214
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Caching & WAF (CloudFront Edge)
|
|
2
|
+
|
|
3
|
+
> The edge pipeline: WAF filters malicious traffic, edge auth verifies JWTs, CloudFront caches public GETs and SSR HTML, the handler sets `Cache-Control` by auth state, and mutations bump epoch keys that invalidate stale entries. Read the WAF section BEFORE enabling managed rules — the default managed rules 403 every PostgREST/JSON API request without the shipped exclusions.
|
|
4
|
+
|
|
5
|
+
## THE TRAP — AWS managed rules 403 your JSON API
|
|
6
|
+
|
|
7
|
+
AWS WAF managed rules inspect the **request body and headers**, not just the URL. The moment you enable them on a PostgREST/JSON API, several rules fire on legitimate traffic and return **403 before the request ever reaches Lambda**:
|
|
8
|
+
|
|
9
|
+
- `AWSManagedRulesSQLiRuleSet` → **`SQLi_BODY`**: PostgREST JSON keys (`limit`, `select`, `order`, `sort`) read as SQL keywords. Every `POST`/RPC and every `select()` with those keys 403s.
|
|
10
|
+
- `AWSManagedRulesCommonRuleSet` → **`SizeRestrictions_BODY`** (large JSON payloads) and **`GenericRFI_BODY`** (URLs inside JSON values look like remote-file-inclusion).
|
|
11
|
+
- `AWSManagedRulesKnownBadInputsRuleSet` → **`Log4JRCE_HEADER`**: the JWT in the `Authorization` header trips the Log4Shell signature.
|
|
12
|
+
|
|
13
|
+
These are false positives — Drizzle parameterizes every query server-side, so none of it is real injection. But WAF has no application context and blocks anyway.
|
|
14
|
+
|
|
15
|
+
> Do NOT "exclude `SQLi_QUERYARGUMENTS`." That rule lives in `AWSManagedRulesSQLiRuleSet`, and a `ruleActionOverride` naming a rule that isn't in the group you attach it to **silently no-ops** — the exclusion looks configured while the BODY rules keep 403-ing. Exclude the BODY/HEADER rules named above.
|
|
16
|
+
|
|
17
|
+
### The fix — two composed transforms
|
|
18
|
+
|
|
19
|
+
everystack ships the exclusion set and a rate-limit-429 transform in `@everystack/server/cdn`. Set the false-positive rules to **COUNT** mode (they still log to CloudWatch `AWS/WAFV2`, they just stop blocking) and answer rate limits with **429** instead of 403:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { applyWafExclusions, applyWafRateLimit429 } from '@everystack/server/cdn';
|
|
23
|
+
|
|
24
|
+
// MUST be registered BEFORE `new sst.aws.Router(...)`. A $transform only applies to
|
|
25
|
+
// resources created AFTER it, and the Router is what creates the WebAcl — register it
|
|
26
|
+
// after the Router and it silently no-ops.
|
|
27
|
+
$transform(aws.wafv2.WebAcl, (args: any) => {
|
|
28
|
+
if (!args.rules) return;
|
|
29
|
+
args.rules = $resolve(args.rules).apply(
|
|
30
|
+
(rules: any[]) => applyWafRateLimit429(applyWafExclusions(rules)),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const router = new sst.aws.Router('Router', {
|
|
35
|
+
// ...
|
|
36
|
+
waf: {
|
|
37
|
+
rateLimitPerIp: 2000,
|
|
38
|
+
managedRules: { common: true, sqli: true, knownBadInputs: true },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **COUNT ≠ disabled.** The rule still evaluates and records to CloudWatch — you keep visibility, you just stop the false-positive block. Nothing is turned off.
|
|
44
|
+
- **`applyWafExclusions(rules, overrides?)`** defaults to the shipped `WAF_JSON_API_OVERRIDES` map. Pass a second argument (rule group → rule names) to extend it if you hit a new false positive.
|
|
45
|
+
- **`applyWafRateLimit429`** rewrites rate-based block actions to `429 + Retry-After`. This is SEO-load-bearing, not cosmetic: Google reads any 4xx except 429 as "gone" and deindexes the URL over days; 429 means "back off and retry." A rate-limited public SSR site returning 403 becomes a deindexing engine.
|
|
46
|
+
|
|
47
|
+
### GET-filter false positives (diagnosing new ones)
|
|
48
|
+
|
|
49
|
+
PostgREST GET syntax (`?title=eq.hello`, `?or=(status.eq.draft,status.eq.review)`) can trip query-argument SQLi signatures that are NOT in the default map. Don't guess — turn on `waf.logging`, read the WAF **sampled requests** in the console to find the exact rule that matched, then add it via the second argument to `applyWafExclusions`. Set the offending rule to COUNT, confirm the request passes, and keep watching the count metric.
|
|
50
|
+
|
|
51
|
+
## WAF configuration
|
|
52
|
+
|
|
53
|
+
`sst.aws.Router`'s `waf` block configures rate limiting and managed rule sets:
|
|
54
|
+
|
|
55
|
+
| Field | Purpose |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `rateLimitPerIp` | Per-IP request cap over a 5-minute sliding window. On exceed, WAF 403s (429 with the transform above) until the window resets. `2000` (~6.7 req/s sustained) suits most APIs. |
|
|
58
|
+
| `managedRules.common` | `AWSManagedRulesCommonRuleSet` — broad OWASP-style protections. |
|
|
59
|
+
| `managedRules.sqli` | `AWSManagedRulesSQLiRuleSet` — SQL-injection signatures. |
|
|
60
|
+
| `managedRules.knownBadInputs` | `AWSManagedRulesKnownBadInputsRuleSet` — known exploit payloads (Log4Shell, etc.). |
|
|
61
|
+
|
|
62
|
+
All three managed sets are AWS Free-Tier rules (~$1/rule-group/month + per-request); no per-request cost beyond base WAF pricing.
|
|
63
|
+
|
|
64
|
+
**What WAF does NOT cover** (it sees IPs and HTTP, not application state):
|
|
65
|
+
- Per-account auth brute force — that belongs in `@everystack/auth` (account lockout, progressive delays). WAF rate limiting is per-IP.
|
|
66
|
+
- Bot quality — AWS Bot Control (Targeted) can fingerprint sophisticated bots but costs $10/M requests; managed rules + rate limiting suffice for most apps.
|
|
67
|
+
|
|
68
|
+
## The rest of the edge pipeline
|
|
69
|
+
|
|
70
|
+
**Edge auth.** For latency-sensitive authenticated paths, a CloudFront function verifies the JWT signature at the edge before the request reaches Lambda. See `everystack://auth` for `authFeature` / `composeViewerRequest` and edge token lifetimes (keep edge access tokens short, ~15 min).
|
|
71
|
+
|
|
72
|
+
**Response caching (Cache-Control by auth state).** The handler sets headers based on authentication:
|
|
73
|
+
- Public GETs and SSR HTML → cacheable at the edge (`public, max-age=...`). SSR HTML is the same for every visitor (good for SEO); user-specific content loads on the client during rehydration.
|
|
74
|
+
- Authenticated API responses → `private, no-store`, bypassing the CDN cache entirely.
|
|
75
|
+
|
|
76
|
+
**Epoch invalidation.** Mutations bump an epoch-based version key (in the CloudFront KVS), which naturally invalidates stale public cache entries — no manual purging. Reads carry the current epoch; a write advances it so subsequent reads miss the old cached copy.
|
|
77
|
+
|
|
78
|
+
**Debugging an edge 403.** A 403 with **no corresponding Lambda log entry** was blocked at the edge (WAF or edge auth), not by your handler. Check the WAF sampled requests for the matched rule, confirm with `curl -sI` against the API path, and reach for the exclusions transform above. A 403 that DOES reach Lambda is an application authz decision, not WAF.
|
|
@@ -59,6 +59,9 @@ export default $config({
|
|
|
59
59
|
const router = new sst.aws.Router('Router', {
|
|
60
60
|
routes: { '/*': api.url },
|
|
61
61
|
});
|
|
62
|
+
// Adding WAF (`waf: { managedRules, rateLimitPerIp }`)? Read everystack://caching
|
|
63
|
+
// FIRST — AWS managed rules 403 every PostgREST request without the shipped
|
|
64
|
+
// exclusions transform, which must register BEFORE this Router.
|
|
62
65
|
|
|
63
66
|
// CLI needs these outputs
|
|
64
67
|
return {
|
|
@@ -92,6 +92,7 @@ Your OTHER `+api.ts` routes (`app/mcp+api.ts`, `app/games/[id]+api.ts`, ...) nee
|
|
|
92
92
|
|
|
93
93
|
```typescript
|
|
94
94
|
// server/ops.ts
|
|
95
|
+
import { Resource } from 'sst';
|
|
95
96
|
import { createPluginLambdaHandler, dbPlugin } from '@everystack/server/plugin';
|
|
96
97
|
import { createAppContext, appPlugins } from './context';
|
|
97
98
|
|
|
@@ -104,6 +105,10 @@ export const handler = createPluginLambdaHandler({
|
|
|
104
105
|
migrationsFolder: 'drizzle',
|
|
105
106
|
seed: async (db, schema) => (await import('../db/seed.js')).runSeed(db, schema),
|
|
106
107
|
// ^ lazy import — a top-level import would run at Lambda INIT and crash the boot
|
|
108
|
+
backupBucket: Resource.Backups?.name,
|
|
109
|
+
// ^ registers db:backup / db:backups / db:restore. Omit it and those actions
|
|
110
|
+
// return "backup storage not configured"; omit the whole ops Lambda and the
|
|
111
|
+
// CLI can't reach them at all — it reports "Unknown action" (see Gotchas).
|
|
107
112
|
}),
|
|
108
113
|
],
|
|
109
114
|
});
|
|
@@ -133,6 +138,7 @@ export default $config({
|
|
|
133
138
|
|
|
134
139
|
const updates = new sst.aws.Bucket('Updates'); // published Expo exports (server bundles)
|
|
135
140
|
const clientBundles = new sst.aws.Bucket('ClientBundles', { access: 'cloudfront' });
|
|
141
|
+
const backups = new sst.aws.Bucket('Backups'); // private — logical pg_dump backups (db:backup)
|
|
136
142
|
|
|
137
143
|
const jwtSecret = new sst.Secret('JwtSecret');
|
|
138
144
|
// Least-privilege API credential — written by `everystack db:provision`.
|
|
@@ -162,17 +168,19 @@ export default $config({
|
|
|
162
168
|
runtime: 'nodejs20.x',
|
|
163
169
|
timeout: '120 seconds',
|
|
164
170
|
vpc,
|
|
165
|
-
link: [database, databaseUrl, updates, clientBundles, jwtSecret],
|
|
171
|
+
link: [database, databaseUrl, updates, clientBundles, jwtSecret, backups],
|
|
166
172
|
copyFiles: [{ from: 'drizzle', to: 'drizzle' }], // migrations must ship with the ops bundle
|
|
167
173
|
});
|
|
168
174
|
|
|
169
|
-
// The CLI discovers resources through these outputs
|
|
175
|
+
// The CLI discovers resources through these outputs. The first five are required;
|
|
176
|
+
// backupsBucket unlocks db:backup / db:swap / db:fork (also auto-discovered by bucket name).
|
|
170
177
|
return {
|
|
171
178
|
routerUrl: router.url,
|
|
172
179
|
apiFunctionName: api.name,
|
|
173
180
|
opsFunctionName: ops.name,
|
|
174
181
|
updatesBucket: updates.name,
|
|
175
182
|
clientBundlesBucket: clientBundles.name,
|
|
183
|
+
backupsBucket: backups.name,
|
|
176
184
|
};
|
|
177
185
|
},
|
|
178
186
|
});
|
|
@@ -205,4 +213,5 @@ Subsequent app-code ships are `everystack update` alone — no `sst deploy`, no
|
|
|
205
213
|
- **A path claimed by a plugin never reaches your bundle's route.** If you mount the everystack handler at `/api`, a bundle route at `app/api/foo+api.ts` is shadowed in production (it still serves in local dev unless the catch-all shadows it there too — keep the surfaces aligned).
|
|
206
214
|
- **Lazy-load your seed** (shown above). A top-level `import { runSeed }` in an entrypoint runs at Lambda INIT, tries to reach the database before configuration, and kills the boot.
|
|
207
215
|
- **`copyFiles` the migrations folder** into the ops function, or `db:migrate` fails with ENOENT.
|
|
216
|
+
- **`db:backup`/`db:export`/`db:restore` run in the ephemeral Fargate Task lane — wire `dbTaskPlugin({ backupBucket })` on the ops function.** They are pg_dump/pg_restore work; the ops Lambda holds no pg binaries. `everystack db:backup` reporting `Unknown action: db:backup` means the CLI reached a function with no `dbTaskPlugin` mounted (usually: no ops function at all, so `opsFunctionName` fell back to the Api Lambda). Deploy the `Ops` function above with `dbTaskPlugin`. If instead it reports `backup storage not configured`, `dbTaskPlugin` is mounted but `backupBucket` is unset — pass `() => Resource.Backups.name`. The Task image's `pg_dump`-vs-server compatibility is verified on demand by `everystack task:probe` (there is no Lambda pg_dump layer).
|
|
208
217
|
- **`+api.ts` routes run with the bundle's environment**, not per-route SST links. Resource-linked work (secrets, buckets) belongs in plugins on the Lambda side; keep bundle routes to app logic over the request.
|
package/src/resources/index.ts
CHANGED
|
@@ -140,6 +140,13 @@ const RESOURCES: ResourceDef[] = [
|
|
|
140
140
|
'SST deployment: sst.config.ts setup, secrets, stages (dev/production), CloudFront, Lambda configuration, VPC, RDS Aurora Serverless, resource linking.',
|
|
141
141
|
filename: 'deployment.md',
|
|
142
142
|
},
|
|
143
|
+
{
|
|
144
|
+
uri: 'everystack://caching',
|
|
145
|
+
name: 'Caching & WAF (CloudFront Edge)',
|
|
146
|
+
description:
|
|
147
|
+
'READ BEFORE enabling WAF on the Router. AWS managed rules 403 every PostgREST/JSON API request without the shipped exclusions transform (applyWafExclusions/applyWafRateLimit429 from @everystack/server/cdn). Also: edge auth, response caching by auth state, epoch invalidation, and debugging edge 403s.',
|
|
148
|
+
filename: 'caching.md',
|
|
149
|
+
},
|
|
143
150
|
{
|
|
144
151
|
uri: 'everystack://expo-server-deploy',
|
|
145
152
|
name: 'Deploying an Expo Router Server App',
|
|
@@ -263,8 +263,8 @@ RESET ROLE;
|
|
|
263
263
|
- [ ] Bcrypt cost >= 10
|
|
264
264
|
|
|
265
265
|
### Infrastructure
|
|
266
|
-
- [ ] WAF managed rules
|
|
267
|
-
- [ ] Rate limiting (
|
|
266
|
+
- [ ] WAF managed rules — set the JSON-API false-positive BODY/HEADER rules (SQLi_BODY, SizeRestrictions_BODY, GenericRFI_BODY, Log4JRCE_HEADER) to COUNT via `applyWafExclusions`, or they 403 every PostgREST request. See everystack://caching (read BEFORE enabling `waf` on the Router).
|
|
267
|
+
- [ ] Rate limiting — 429 not 403 (applyWafRateLimit429); ~2000 req/5 min per IP. See everystack://caching.
|
|
268
268
|
- [ ] CloudFront edge JWT verification for latency-sensitive paths
|
|
269
269
|
- [ ] Database credentials via SST Resource linking
|
|
270
270
|
- [ ] No debug endpoints or env dumps in production
|