Skip to content

Commit 9dc7919

Browse files
bpamiriclaude
andauthored
docs(web/guides): fix audited rate-limiting, CORS, and datasource guide errors (#3095)
* docs(web/guides): fix audited rate-limiting, CORS, and datasource guide errors Guide-behavioral-audit batch 2 (p1-11-ratelimit, p1-12-cors, p1-13-datasources) corrections, all live-verified against develop on Lucee 7 + Adobe 2023: rate-limiting.mdx - keyFunction example read req.cgi, which the middleware context never carries, collapsing all clients into one anonymous budget; switched to the verified cgi-scope + Len() guard form and documented the context shape (#3074) - per-route example used .scope(callback=...), which scope() silently ignores while the unclosed scope 404s the rest of the app; rewrote to the explicit .scope(...)...end() form and called out the footgun (#3072) - corrected the empty-string-key rationale (budget collapse, not entry growth) - added an aside: the welcome-page root bypasses the middleware pipeline cors.mdx - per-route example had the same non-functional .scope(callback=...) shape; rewrote to the stack form matching middleware-pipeline.mdx (#3072) - origin matching is case-insensitive (ListFindNoCase), not character-for-character - preflight short-circuit responds HTTP 200, not a 204-shaped body; noted Access-Control-Max-Age is emitted even for disallowed origins database-and-multiple-datasources.mdx - tableName("X") is a getter no-op (setter is table()); fixed both examples and added a caution (#3079) - findAll default returns a query, not model instances; reworded the framing - documented the invokeWithTransaction boolean-return contract - noted the Adobe-vs-Lucee cross-datasource transaction divergence - clarified the transaction="commit" default chain - added CockroachDB + Oracle to the adapter list; H2 noted Lucee-only in CI - removed the internal 'Phase 2b' forward reference Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> * docs(web/guides): scope rate-limit bypass aside to the internal Wheels controller The aside claimed a fresh app's GET / never counts against a rate-limit budget. That's only true when root() falls back to the built-in wheels##wheels welcome page (no to= and no app/views/home/index.cfm) — the audit-harness shape. A canonical 'wheels new' app routes root to main##index through a generated user controller, so GET / runs the middleware pipeline and does count. Recondition the aside on the internal Wheels controller (/wheels GUI routes or a root left on the built-in welcome page) per the audit evidence, which scoped this as harness-relevant rather than a general claim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 70eb85c commit 9dc7919

3 files changed

Lines changed: 39 additions & 24 deletions

File tree

web/sites/guides/src/content/docs/v4-0-0/basics/database-and-multiple-datasources.mdx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Wheels reads `dataSourceName` from `config/settings.cfm` at application start an
3030
set(dataSourceName = "myapp_dev");
3131
```
3232

33-
The name you set here must match a datasource registered with your CFML engine. In Lucee that's configured in `lucee.json` (or `/lucee/admin/` for server-scope datasources); in Adobe it's in the admin UI. Check your engine's documentation for the registration shape — Wheels only cares that the name resolves to a working connection.
33+
The name you set here must match a datasource registered with your CFML engine. In Lucee that's configured in `lucee.json` (or `/lucee/admin/` for server-scope datasources); in Adobe it's in the admin UI. Check your engine's documentation for the registration shape — Wheels only cares that the name resolves to a working connection. In development, a name that doesn't resolve surfaces as a `Wheels.DataSourceNotFound` error page naming the datasource (served with HTTP 404).
3434

3535
`config/settings.cfm` is the shared file loaded in every environment. The next section shows how to override it per environment.
3636

@@ -54,14 +54,18 @@ Any model can point at a different datasource by calling `dataSource()` in `conf
5454
component extends="Model" {
5555
function config() {
5656
dataSource("legacy_reporting");
57-
tableName("vw_legacy_users");
57+
table("vw_legacy_users");
5858
setPrimaryKey("user_id");
5959
}
6060
}
6161
```
6262

6363
The typical legacy case is all three overrides at once: a non-default datasource, a non-convention table name (often a view), and a non-`id` primary key. The rest of the model still behaves like any other — associations, validations, finders, and the query builder all use the datasource you've declared.
6464

65+
<Aside type="caution">
66+
The table-name **setter** is `table()`. `tableName()` is a zero-argument getter — calling `tableName("vw_legacy_users")` silently ignores the argument, the model resolves the conventional table name instead, and the first query throws `Wheels.TableNotFound` ([#3079](https://github.com/wheels-dev/wheels/issues/3079)).
67+
</Aside>
68+
6569
`dataSource()` also accepts `username` and `password` arguments for databases that need per-model credentials:
6670

6771
```cfm {test:compile}
@@ -88,7 +92,7 @@ set(dataSourceName = "myapp_primary");
8892
component extends="Model" {
8993
function config() {
9094
dataSource("myapp_replica");
91-
tableName("users");
95+
table("users");
9296
setPrimaryKey("id");
9397
}
9498
}
@@ -102,7 +106,7 @@ A framework-level read/write split is under consideration for a future release.
102106

103107
Wheels gives you three ways to wrap work in a transaction. Which one you pick depends on how many statements you're coordinating.
104108

105-
**Single model method.** Every persistence method — `save`, `create`, `update`, `delete` — accepts a `transaction` argument. Pass `"rollback"` to run the statement inside a transaction and always roll it back (useful for dry-runs and tests) or `"commit"` (the default when `transactionMode` is set) to commit normally.
109+
**Single model method.** Every persistence method — `save`, `create`, `update`, `delete` — accepts a `transaction` argument. Pass `"rollback"` to run the statement inside a transaction and always roll it back (useful for dry-runs and tests) or `"commit"` (the default — persistence methods default their `transaction` argument to the `transactionMode` setting, whose framework default is `"commit"`) to commit normally.
106110

107111
```cfm {test:compile}
108112
component extends="Controller" {
@@ -125,6 +129,8 @@ component extends="Controller" {
125129
}
126130
```
127131

132+
The invoked method must return a boolean — `invokeWithTransaction()` throws `Methods invoked using invokeWithTransaction must return a boolean value` otherwise. Returning `true` commits; returning `false` rolls the transaction back, even with `transaction="commit"`. Make sure `settleOutstandingCharges()` ends with `return true;` on its success path.
133+
128134
**Multiple statements, multiple models.** Use a native CFML `transaction{}` block. Everything inside runs against the same connection and commits or rolls back as a unit. An uncaught exception inside rolls back automatically; an explicit rollback uses `transaction action="rollback"`.
129135

130136
```cfm {test:compile}
@@ -141,7 +147,7 @@ component extends="Controller" {
141147
}
142148
```
143149

144-
Transactions commit when the block exits cleanly and roll back on any thrown exception. All statements in a transaction must run against the same datasource — cross-database transactions aren't a thing the engine can give you.
150+
Transactions commit when the block exits cleanly and roll back on any thrown exception. All statements in a transaction must run against the same datasource — cross-database transactions aren't a thing the engine can give you. Adobe ColdFusion enforces this with an error (`Datasource names for all the database tags within the cftransaction tag must be the same.`); Lucee 7 does not — it runs the statements against both datasources but provides no cross-database atomicity, so the failure mode is silent.
145151

146152
## Raw queries
147153

@@ -159,9 +165,9 @@ component extends="Model" {
159165
}
160166
```
161167

162-
The `?` placeholder plus the `parameters` array gets you parameter binding through `cfqueryparam`. Never concatenate user input into the SQL string — that's the canonical SQL-injection gap. A hardening walkthrough lands in Phase 2b; until then the rule is: if a value comes from `params`, `session`, or any request-scoped source, it goes through a placeholder, not through string interpolation.
168+
The `?` placeholder plus the `parameters` array gets you parameter binding through `cfqueryparam`. Never concatenate user input into the SQL string — that's the canonical SQL-injection gap. The rule is: if a value comes from `params`, `session`, or any request-scoped source, it goes through a placeholder, not through string interpolation.
163169

164-
For queries that return model instances rather than a raw query result, stay with `findAll(where="...")` or the [query builder](/v4-0-0/basics/query-builder-and-scopes/). Raw `queryExecute()` is for the cases where you genuinely want a query object, not an array of objects.
170+
For model instances, use `findOne()`/`findByKey()` or `findAll(returnAs="objects")` — plain `findAll()` already returns a query result by default, so reach for raw `queryExecute()` only when neither the ORM nor the [query builder](/v4-0-0/basics/query-builder-and-scopes/) can express the SQL you need.
165171

166172
## Connection pooling
167173

@@ -175,7 +181,9 @@ Wheels 4.0 has database adapters for:
175181
- **MySQL / MariaDB** — fully supported, broad production use.
176182
- **Microsoft SQL Server** — fully supported, common in enterprise shops.
177183
- **SQLite** — great for development and testing, production-fine for small apps.
178-
- **H2** — embedded, great for tests that need a fresh database per run.
184+
- **H2** — embedded, great for tests that need a fresh database per run (Lucee-only in the CI matrix).
185+
- **CockroachDB** — distributed SQL, PostgreSQL wire-compatible, with its own adapter.
186+
- **Oracle** — supported with its own adapter (runs as a soft-fail leg in the CI matrix).
179187

180188
The Wheels 4.0 core test suite runs on Lucee 7 + SQLite for day-to-day development; the full CI matrix covers additional engines and databases. SQLite is the reference — if something works there but not on your target database, that's a bug worth filing.
181189

web/sites/guides/src/content/docs/v4-0-0/digging-deeper/cors.mdx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ This page shows you how to let a browser on `https://client.example.com` call yo
2828

2929
CORS — Cross-Origin Resource Sharing — is a browser-enforced rule. A script running on origin A (scheme + host + port, e.g. `https://client.example.com`) cannot read responses from a different origin B unless B explicitly opts in with `Access-Control-*` response headers. Server-to-server HTTP calls don't have CORS; it's purely a browser-side policy enforced for JavaScript `fetch`, `XMLHttpRequest`, and `EventSource`.
3030

31-
The `wheels.middleware.Cors` middleware writes those opt-in headers onto every matching response and short-circuits preflight `OPTIONS` requests with a 204-shaped empty body.
31+
The `wheels.middleware.Cors` middleware writes those opt-in headers onto every matching response and short-circuits preflight `OPTIONS` requests with an empty-body response (HTTP 200).
3232

3333
## Enable the middleware
3434

@@ -62,7 +62,7 @@ These are the constructor arguments on `wheels.middleware.Cors`, verified agains
6262
| `allowCredentials` | `false` | When `true`, browsers may include cookies and `Authorization` headers. Forbids `allowOrigins="*"`. |
6363
| `maxAge` | `86400` (24 hours) | `Access-Control-Max-Age` — how long the browser may cache the preflight response. |
6464

65-
Matching is **exact string comparison** on the incoming `Origin` header. There is no wildcard-subdomain support: `https://*.myapp.com` is not a valid entry. Enumerate every origin you need — scheme + host + port, nothing more, nothing less.
65+
Matching is an **exact match** on the incoming `Origin` header, compared case-insensitively. There is no wildcard-subdomain support: `https://*.myapp.com` is not a valid entry. Enumerate every origin you need — scheme + host + port, nothing more, nothing less.
6666

6767
<Aside type="caution">
6868
**Migrating from 3.x global settings?** The `allowHeaders` default above (`"Content-Type,Authorization,X-Requested-With"`) is narrower than the legacy `accessControlAllowHeaders` global-setting default, which also included `X-Auth-Token`, `X-Requested-By`, and `Origin`. A like-for-like swap silently drops those headers. See [Upgrading from 3.x — CORS allow-list defaults drift](/v4-0-0/upgrading/3x-to-4x/#migrating-from-global-settings-to-the-middleware) for the side-by-side comparison and the explicit-constructor-args fix.
@@ -73,7 +73,7 @@ Matching is **exact string comparison** on the incoming `Origin` header. There i
7373
Before any non-simple cross-origin request (anything that uses a custom header, a non-GET/POST/HEAD method, or a non-simple content type), the browser sends a preflight `OPTIONS` request asking "may I?" The Cors middleware handles this for you:
7474

7575
- If the `Origin` is in `allowOrigins` (or `allowOrigins` is `"*"`), the middleware writes `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`, optionally `Access-Control-Allow-Credentials`, plus `Access-Control-Max-Age`, and returns an empty response without running your controller.
76-
- If the `Origin` is not in the allowlist, the middleware still short-circuits the `OPTIONS` request with an empty body but **omits the `Access-Control-Allow-*` headers**. The browser sees no opt-in and blocks the subsequent real request.
76+
- If the `Origin` is not in the allowlist, the middleware still short-circuits the `OPTIONS` request with an empty body but **omits the `Access-Control-Allow-*` headers**. The browser sees no opt-in and blocks the subsequent real request. (`Access-Control-Max-Age` is emitted on every `OPTIONS` response, including disallowed origins.)
7777

7878
You do not write an `options` action on your controllers. The middleware owns `OPTIONS` entirely.
7979

@@ -89,7 +89,7 @@ Three rules come with it:
8989

9090
## Per-route Cors
9191

92-
Most real apps want strict CORS only on `/api` and not on the HTML pages. Pass middleware to a `scope()` block and the rules apply only to routes declared inside the callback:
92+
Most real apps want strict CORS only on `/api` and not on the HTML pages. Pass middleware to a `scope()` block and the rules apply only to the routes declared before the scope's closing `.end()`:
9393

9494
```cfm {test:compile} title="config/routes.cfm"
9595
<cfscript>
@@ -101,12 +101,11 @@ mapper()
101101
allowOrigins="https://client.myapp.com",
102102
allowCredentials=true
103103
)
104-
],
105-
callback=function(map) {
106-
map.resources("posts");
107-
map.resources("comments");
108-
}
104+
]
109105
)
106+
.resources("posts")
107+
.resources("comments")
108+
.end()
110109
.resources("pages")
111110
.wildcard()
112111
.end();
@@ -115,6 +114,8 @@ mapper()
115114

116115
Routes under `/api` get the Cors middleware. `resources("pages")` and everything outside the scope do not. Scope-level middleware composes with global middleware — both run.
117116

117+
Close the scope with `.end()` before declaring routes outside it — `scope()` does not take a `callback` argument. A callback passed to it is silently ignored (the routes inside it never register), and the unclosed scope leaks its path prefix and middleware onto every subsequent route ([#3072](https://github.com/wheels-dev/wheels/issues/3072)).
118+
118119
<Aside type="caution">
119120
The preflight short-circuit that prevents unmatched `OPTIONS` requests from reaching the route table applies only when `Cors` is registered in the **global** pipeline via `config/settings.cfm`. Route-scoped `Cors` declared inside `.scope()` in `config/routes.cfm` does not benefit: route matching runs before route-scoped middleware executes, so a browser preflight to a path that only declares `POST` will 404 with `Wheels.RouteNotFound` unless `Cors` is also in the global pipeline.
120121

@@ -139,7 +140,7 @@ When a cross-origin request fails, work through this checklist:
139140
-H "Access-Control-Request-Headers: Content-Type" \
140141
-v https://api.myapp.com/posts
141142
```
142-
- **Match the origin exactly.** Origins are strings, compared character-for-character. `https://myapp.com` is not `http://myapp.com`, not `https://www.myapp.com`, not `https://myapp.com:8080`. All four are distinct origins.
143+
- **Match the origin exactly.** Origins are strings, compared exactly (case-insensitively). `https://myapp.com` is not `http://myapp.com`, not `https://www.myapp.com`, not `https://myapp.com:8080`. All four are distinct origins.
143144

144145
## Common pitfalls
145146

web/sites/guides/src/content/docs/v4-0-0/digging-deeper/rate-limiting.mdx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ set(middleware = [
4848

4949
With no other arguments you get: `strategy="fixedWindow"`, `storage="memory"`, and keying by client IP. Every client gets 60 requests per 60 seconds.
5050

51+
<Aside type="note">
52+
Requests handled by the internal Wheels controller — the `/wheels` GUI routes, or a root route left pointing at the built-in welcome page instead of one of your controllers — bypass the middleware pipeline and never count against a budget. Test rate limiting against one of your own controller routes.
53+
</Aside>
54+
5155
## Sliding window
5256

5357
Swap the strategy for smoother enforcement:
@@ -135,14 +139,15 @@ set(middleware = [
135139
maxRequests=1000,
136140
windowSeconds=60,
137141
keyFunction=function(req) {
138-
return req.cgi.http_x_api_key ?: "anonymous";
142+
var apiKey = cgi.http_x_api_key;
143+
return Len(apiKey) ? apiKey : "anonymous";
139144
}
140145
)
141146
]);
142147
</cfscript>
143148
```
144149

145-
The closure receives the request struct and returns a unique-per-client string. Fall back to a constant like `"anonymous"` so unauthenticated traffic still hits some limit — otherwise an attacker who omits the header accumulates counter entries under whatever empty-string key your code returns.
150+
The closure receives the middleware request context — a struct of `params`, `route`, `pathInfo`, and `method` — and returns a unique-per-client string. The context does not carry a `cgi` key ([#3074](https://github.com/wheels-dev/wheels/issues/3074)), so read headers from the engine's `cgi` scope directly, as above. Note the `Len()` guard: a missing header reads as an **empty string** in the `cgi` scope, not undefined, so `cgi.http_x_api_key ?: "anonymous"` would never fall back. Fall back to a constant like `"anonymous"` so unauthenticated traffic still hits some limit — otherwise every header-less request shares whatever empty-string key your code returns: one merged budget you never intended.
146151

147152
<Aside type="tip">
148153
Keys longer than `maxKeyLength` (default 128) are auto-replaced with a SHA-256 hash to bound memory. You don't need to truncate keys yourself.
@@ -190,17 +195,18 @@ mapper()
190195
maxRequests=5,
191196
windowSeconds=60
192197
)
193-
],
194-
callback=function(map) {
195-
map.post(name="authenticate", pattern="/", to="sessions##create");
196-
}
198+
]
197199
)
200+
.post(name="authenticate", pattern="/", to="sessions##create")
201+
.end()
198202
.resources("users")
199203
.wildcard()
200204
.end();
201205
</cfscript>
202206
```
203207

208+
Declare the scoped routes between `.scope()` and its matching `.end()`, then close the scope before any routes that shouldn't share the limit. `scope()` does not take a `callback` argument — a callback passed to it is silently ignored, and without the `.end()` every subsequent route (including `.wildcard()`) nests under `/login`, breaking the rest of the app's routing ([#3072](https://github.com/wheels-dev/wheels/issues/3072)).
209+
204210
Global middleware from `config/settings.cfm` still runs — it composes with the scope-level one. The pattern is: a permissive global limit (say 60/min) plus a tight limit on sensitive routes (5/min on login) so a brute-force attempt trips the narrow limit long before the broad one.
205211

206212
## Debugging unexpected rejections

0 commit comments

Comments
 (0)