Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The canonical pattern: deny access to anything except the public read actions. A
```cfm {test:compile} title="app/controllers/Posts.cfc"
component extends="Controller" {
function config() {
super.config();
filters(through="authenticate", except="index,show");
}

Expand All @@ -60,13 +61,18 @@ component extends="Controller" {

`except="index,show"` leaves the two read actions public. Every other action — `new`, `create`, `edit`, `update`, `delete` — triggers `authenticate` before the action body runs. The filter returns `void`; short-circuit is signalled by calling `redirectTo`.

<Aside type="caution">
Keep `super.config()` as the first line of every `config()` override. The default app template's parent `Controller.cfc` calls `protectsFromForgery()` in its `config()` — omit `super.config()` and CSRF protection silently drops for that controller. In development Wheels logs a one-time warning ("Controller 'Posts' overrides config() without calling super.config()...") to `wheels.log` and the debug bar when this happens.
</Aside>

## Ownership checks

Authentication proves who you are. Authorization decides whether you're allowed to touch this record. Layer a second filter after `authenticate` that rejects edits on posts owned by somebody else.

```cfm {test:compile} title="app/controllers/Posts.cfc"
component extends="Controller" {
function config() {
super.config();
filters(through="authenticate", except="index,show");
filters(through="ownershipCheck", only="edit,update,delete");
}
Expand Down Expand Up @@ -94,7 +100,7 @@ component extends="Controller" {
}
```

`params.post` is a model instance populated by route model binding, not a raw struct. Without binding, `params.post` doesn't exist and the dev-mode warning fires telling you to enable it. See [Route Model Binding](/v4-0-0/basics/routing/) for the one-line fix (`binding=true` on the resource).
`params.post` is a model instance populated by route model binding, not a raw struct. Without binding, `params.post` doesn't exist — on screen you get an undefined-variable error, and Wheels writes a one-time hint per controller + action to `wheels.log` (in any environment except production) telling you to enable binding. See [Route Model Binding](/v4-0-0/basics/routing/) for the one-line fix (`binding=true` on the resource).

<Aside type="tip">
Ownership checks are cheap: one comparison. If you find yourself hitting the database inside the filter to reload the record, stop — either turn on route model binding and reuse `params.post`, or pull the authorization decision out into a policy object (below).
Expand All @@ -104,9 +110,10 @@ component extends="Controller" {

For admin-only actions, read the current user's role and reject the rest. The tidy version uses a dedicated filter and a finder — keep the query off the hot path of every request by scoping it to admin-gated actions only.

```cfm {test:compile} title="app/controllers/Admin/Users.cfc"
```cfm {test:compile} title="app/controllers/AdminUsers.cfc"
component extends="Controller" {
function config() {
super.config();
filters(through="authenticate");
filters(through="requireAdmin");
}
Expand All @@ -125,6 +132,8 @@ component extends="Controller" {
}
```

The flat `AdminUsers.cfc` is reachable at `/adminusers` through the default wildcard route. A nested `app/controllers/Admin/Users.cfc` only works if you add a route that targets it (the default route set has none — `/admin/users` parses as controller `admin`, action `users` and 404s).

On the model side, keep role predicates out of ad-hoc WHERE strings. Use a named scope or the query builder — see [Query Builder and Scopes](/v4-0-0/basics/query-builder-and-scopes/) for `model("User").where("role", "admin").get()` and the `scope(name="admins", where="role = 'admin'")` shorthand.

## `verifies()` — type and presence guards
Expand All @@ -134,6 +143,7 @@ On the model side, keep role predicates out of ad-hoc WHERE strings. Use a named
```cfm {test:compile} title="app/controllers/Posts.cfc"
component extends="Controller" {
function config() {
super.config();
verifies(
only="show,edit,update,delete",
params="key",
Expand All @@ -149,12 +159,17 @@ component extends="Controller" {
}

private function invalidRequest() {
renderText(text="Bad request", status=400);
flashInsert(error="That request didn't look right.");
redirectTo(route="posts");
}
}
```

Arguments match the framework signature: `params` is a comma-list of expected keys, `paramsTypes` is a parallel list of types passed through to `IsValid()` (`integer`, `numeric`, `email`, `uuid`, `boolean`, `date`, `string`). `handler` names a private method that runs on failure; omit it to fall back to a redirect or abort. Session and cookie keys have their own argument pairs (`session`/`sessionTypes`, `cookie`/`cookieTypes`).
Arguments match the framework signature: `params` is a comma-list of expected keys, `paramsTypes` is a parallel list of types passed through to `IsValid()` (`integer`, `numeric`, `email`, `uuid`, `boolean`, `date`, `string`). `handler` names a private method that runs on failure. Omit `handler` and pass redirect arguments (e.g. `action="index"`) to redirect on failure instead; omit both and a failed verification aborts the request outright — the client receives a blank `200` response, not an error status. Session and cookie keys have their own argument pairs (`session`/`sessionTypes`, `cookie`/`cookieTypes`).

<Aside type="caution">
A `verifies()` handler must redirect (or abort). After the handler returns, Wheels checks whether a redirect was performed and, if not, issues a back-redirect to the referrer (or `/`) — so a handler that only renders, like `renderText(text="Bad request", status=400)`, has its response discarded and the client sees a `302` instead.
</Aside>

<Aside type="caution">
`verifies()` is not authorization — it's shape enforcement. A request with a valid-integer `params.key` passes verification whether or not the user is allowed to see that record. Verification trims the attack surface; filters enforce access.
Expand Down Expand Up @@ -184,6 +199,7 @@ The controller filter asks the policy the question and redirects on a `false` an
```cfm {test:compile} title="app/controllers/Posts.cfc"
component extends="Controller" {
function config() {
super.config();
filters(through="authenticate", except="index,show");
filters(through="authorizeEdit", only="edit,update");
filters(through="authorizeDelete", only="delete");
Expand Down Expand Up @@ -213,11 +229,12 @@ Register `PostPolicy` in the DI container so controllers resolve it by name inst

## Filter order

Before-filters run in the order they register. After-filters run in reverse. Register authentication first, then authorization, then data loading — each layer depends on the previous one succeeding.
Before-filters run in the order they register. After-filters also run in the order they register. Register authentication first, then authorization, then data loading — each layer depends on the previous one succeeding.

```cfm {test:compile} title="app/controllers/Posts.cfc"
component extends="Controller" {
function config() {
super.config();
filters(through="authenticate", except="index,show");
filters(through="ownershipCheck", only="edit,update,delete");
filters(through="loadCategories", only="new,edit");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The rendered tag is `<form enctype="multipart/form-data" method="post" ...>` wit

## Receiving the upload

Wheels does not unwrap the multipart body into a struct on `params`. The form field arrives as a server-side path to a temp file that Lucee parked for you; you take ownership of it with `<cffile action="upload">`. That tag populates a `cffile` struct with details about the uploaded file (server name, client name, size, content type, extension):
Wheels does not unwrap the multipart body into a struct on `params`. The form field arrives as a server-side path to a temp file that Lucee parked for you; you take ownership of it with `<cffile action="upload">`. That tag populates a `cffile` struct with details about the uploaded file (server name, client name, size, content type, extension). The `fileField` attribute must be the exact form-field name that `fileField()` rendered — `user[avatar]`, with brackets, not the dotted `user.avatar`:

```cfm {test:compile} title="app/controllers/Users.cfc"
component extends="Controller" {
Expand All @@ -51,7 +51,7 @@ component extends="Controller" {
local.uploadDir = expandPath("/var/uploads/tmp/");
cffile(
action="upload",
fileField="user.avatar",
fileField="user[avatar]",
destination=local.uploadDir,
nameconflict="makeunique"
);
Expand All @@ -60,7 +60,9 @@ component extends="Controller" {
user.avatarTempPath = local.uploadDir & cffile.serverFile;
user.avatarClientName = cffile.clientFile;
user.avatarSize = cffile.fileSize;
user.avatarContentType = cffile.contentType;
// cffile.contentType is the major type only ("image") — append the
// subtype to get the full MIME type ("image/png") for validation.
user.avatarContentType = cffile.contentType & "/" & cffile.contentSubType;

if (user.save()) {
redirectTo(route="user", key=user.id);
Expand Down Expand Up @@ -124,11 +126,11 @@ component extends="Controller" {
user = model("User").new(params.user);

local.tmpDir = expandPath("/var/uploads/tmp/");
cffile(action="upload", fileField="user.avatar", destination=local.tmpDir, nameconflict="makeunique");
cffile(action="upload", fileField="user[avatar]", destination=local.tmpDir, nameconflict="makeunique");
user.avatarTempPath = local.tmpDir & cffile.serverFile;
user.avatarClientName = cffile.clientFile;
user.avatarSize = cffile.fileSize;
user.avatarContentType = cffile.contentType;
user.avatarContentType = cffile.contentType & "/" & cffile.contentSubType;

if (!user.save()) {
fileDelete(user.avatarTempPath);
Expand Down Expand Up @@ -195,7 +197,7 @@ component extends="Controller" {

Signature: `sendFile(file, name, type, disposition, directory, deleteFile, deliver)`. Defaults worth knowing:

- `file` is resolved relative to the `filePath` setting, which defaults to `files/` under the app root. Pass `directory="/var/uploads"` to serve from outside the app.
- `file` is resolved relative to the `filePath` setting, which defaults to `files/` under the web root (`public/files/` in the default app layout). `directory="/var/uploads"` is meant to serve from outside the web root, but it is currently broken on Adobe ColdFusion (the absolute path gets web-root-prefixed and throws `Wheels.FileNotFound`) and on every engine when the path contains `/wheels` — see [#3077](https://github.com/wheels-dev/wheels/issues/3077). On Lucee, absolute paths without a `/wheels` segment work.
- `name` overrides what the browser shows in the Save dialog. Use it to hide storage filenames from the client.
- `type` overrides the auto-detected MIME type.
- `disposition` is `"attachment"` by default (force download) — pass `"inline"` to render in-browser for PDFs and images.
Expand All @@ -207,13 +209,13 @@ Signature: `sendFile(file, name, type, disposition, directory, deleteFile, deliv

`cfcontent` streams through the servlet output buffer rather than reading the whole file into memory, so `sendFile()` handles multi-hundred-megabyte artifacts without blowing the heap. That said, the request thread is tied up for the entire transfer. For files in the gigabyte range — video, backups, big archives — offload to a CDN or a pre-signed S3 URL so your app server isn't the bottleneck.

If you need range requests (HTTP 206, so browsers can resume a download or seek inside a video), `cfcontent` doesn't emit them. Drop to the raw servlet response via `getPageContext().getResponse()` and stream manually, or front the app with nginx and let it serve the file directly with `X-Accel-Redirect`.
Range requests (HTTP 206, so browsers can resume a download or seek inside a video) are engine-dependent. On Lucee — the stock CommandBox dev stack — `sendFile()` responses honor `Range` headers out of the box: clients get `206 Partial Content` with `Content-Range` and `Accept-Ranges`. Adobe ColdFusion ignores the header and returns the full `200` body. If you need portable range support, drop to the raw servlet response via `getPageContext().getResponse()` and stream manually, or front the app with nginx and let it serve the file directly with `X-Accel-Redirect`.

## Security

Uploads and downloads are where apps leak the most. The short list:

- **Sanitize filenames.** Strip `..`, path separators, null bytes, and shell metacharacters from anything the client sent. Better: don't use the client filename on disk at all — generate a UUID or content hash and keep the original only in a column for the download dialog.
- **Sanitize filenames.** Strip `..`, path separators, null bytes, and shell metacharacters from anything the client sent. Better: don't use the client filename on disk at all — generate a UUID or content hash and keep the original only in a column for the download dialog. On the download side the framework gives you a baseline: `sendFile()` rejects `..` traversal in both `file` and `directory` (throwing `Wheels.InvalidPath`, including URL-encoded and backslash variants; null bytes are stripped) and strips CR/LF, quotes, and backslashes from the `name` before emitting `Content-Disposition`. Treat your own sanitization as defence in depth on top of that, not as the only line.
- **Verify content server-side.** Don't trust `contentType` from the browser. For images, `isImageFile()` parses the bytes. For PDFs, check magic bytes (`%PDF`). For anything else, a library that actually parses the format beats a string comparison on the header.
- **Serve from a separate origin.** Put user uploads on `uploads.example.com`, not the main app domain. A malicious SVG uploaded to the app origin can run JavaScript in your users' sessions; on a separate origin, the same-origin policy contains it. Set `Content-Disposition: attachment` as belt-and-braces so the browser downloads rather than renders.
- **Authorize every download.** Don't hand out predictable URLs like `/uploads/42/avatar.png` from the web root. Route downloads through a controller action that checks ownership, then emits `sendFile()`. Short-lived signed URLs are fine for object-storage backends.
Expand Down
Loading