Safe by default.
What Caramel checks on every request, and what your application must keep doing.
Browser writes need a token and an origin
Caramel checks every POST, PUT, PATCH and DELETE on a route with the default ingress. A request that matches no route is checked too. The Origin header must equal the application’s origin exactly. The submitted token must match the __Host-caramel_csrf cookie, compared in constant time.
Forms send the token in a hidden _csrf field. htmx and fetch send it as X-CSRF-Token. A JSON member named _csrf is not a token. The generated layout puts the header on every htmx request, and generated forms carry the field.
body "hx-boost:inherited": "true", "hx-target:inherited": "#content", "hx-swap:inherited": "innerMorph",
"hx-headers:inherited": {"X-CSRF-Token" => @csrf_token}.to_json doform class: "book-form", action: @action, method: "post" do
input type: "hidden", name: "_csrf", value: @csrf_token
input type: "hidden", name: "_method", value: @method unless @method == "POST"A token is a timestamp, a random nonce and an HMAC-SHA256 signature. It is valid for 24 hours. Every HTML response sets the cookie with Secure, HttpOnly, SameSite=Lax and Path=/. A valid cookie keeps its token; otherwise Caramel issues a new one. A failed check answers 403 with “This form has expired or came from another site. Reload the page and try again.”
APP_ORIGIN must be an HTTPS origin without a path. APP_SECRET must be at least 32 bytes. A _method override reaches a route only when that route reads its body the same way; otherwise the answer is 405.
Sessions are signed and held by the browser
session is a Hash(String, String) stored in the __Host-caramel_session cookie. The cookie holds base64url JSON and an HMAC-SHA256 signature. Its key is derived from APP_SECRET for this one purpose, so a CSRF signature cannot stand in for a session signature. A cookie that is malformed or wrongly signed reads as an empty session.
def handle(contract : Contract) : Caramel::Response
session["user_id"] = contract.user_id
redirect_to "/books"
endA response sets the cookie only when the session changed. sign_out clears the session, and the response deletes the cookie. The cookie has the same Secure, HttpOnly, SameSite=Lax and Path=/ attributes. It has no Max-Age, so it ends with the browser session. There is no server-side expiry or revocation list. A session over 4096 bytes raises Caramel::Session::Overflow; keep ids in the session and records in the database.
The session is readable, though not editable, by anyone holding the cookie. Store no secrets in it. On a route whose ingress sets csrf: false, the session reads empty and is never saved.
Every response carries the same headers
Caramel adds three headers to every response, including static files and error pages.
X-Content-Type-Options: nosniffBrowsers keep the declared content typeReferrer-Policy: same-originOther sites receive no referrerContent-Security-Policydefault-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'The policy blocks inline scripts, inline styles and framing. Put scripts and styles in files under public/, as the generated layout does. HTML and JSON responses from actions also send Cache-Control: no-store.
The Host header must equal the host and port of APP_ORIGIN; any other host gets 421 “Unknown project host”. Forwarded headers grant no trust. A path that does not start with / gets 400.
Views escape text and attributes
Blueprint views escape text and attribute values. The escape covers &, <, >, " and '. Only Caramel::HTML::Safe and Blueprint’s safe(...) values are written as they are. raw accepts only those values. markup { … } builds a small fragment with the same escaping.
morph "#status", with: markup { span { "Status #{count}" } }Wrap a string in Caramel::HTML::Safe only when you built or trust all of its HTML. page(title, body) with a plain String body writes the body as it is, so pass a view or markup instead, or escape with Caramel::HTML.escape. Escaping protects HTML text and quoted attributes only. It does not make a value safe as a URL, script or style, so validate a stored link before writing it into href.
Translated messages keep catalog text as plain text, which the view escapes. When a placeholder value is Caramel::HTML::Safe, the message becomes safe HTML: its catalog text and other values are escaped, and only the safe values are written raw. Translate messages →
Ingress loosens one route at a time
An action’s ingress declaration changes how its route reads the request. Turning CSRF off needs an authenticator. Without one, the build fails with “ingress csrf: false needs authenticate: :method? that verifies a credential a browser does not attach on its own, such as a signature or a bearer token”.
ingress body: :raw,
limit: 256.kilobytes,
csrf: false,
authenticate: :signed?The authenticator runs before the contract binds; false answers 401 “Unauthorized”. body: :raw keeps the exact bytes in raw_body. A body’s default limit is 2 MiB, and limit: sets 1 byte through 64 MiB. A larger body gets 413, and an unsupported media type gets 415. Uploaded files have their own 64 MiB budget. A subtype that redeclares ingress must name its parent’s authenticator again. Receive a signed webhook →
Redirects and outbound calls name their destination
redirect_to accepts only a local absolute path. A path starting with //, or holding a backslash or control character, raises ArgumentError. An htmx request gets 200 with HX-Location instead of a 303.
redirect_external is the explicit way to another site. It accepts an absolute http or https URL with a host and no credentials, whitespace, control characters or backslashes. Its status must be 301, 302, 303, 307 or 308. The same rule, Caramel::ExternalURL.valid?, backs validate_url in changesets, so a saved URL can always be redirected to.
def handle(contract : Contract) : Caramel::Response
return not_found unless Caramel::ExternalURL.valid?(contract.url)
redirect_external contract.url
endCaramel::Outbound.get, post and request call third-party APIs under the same URL rule. They connect within 5 seconds and read within 30. The rule does not restrict which host a URL names, so check user-supplied hosts against your own allowlist before calling them.
headers = HTTP::Headers{"Authorization" => "Bearer #{ENV["API_TOKEN"]}"}
reply = Caramel::Outbound.get("https://api.example.com/status", headers)Static files stay inside public/
Files in public/ are served before routing, after the host check. Caramel refuses a path that would leave that directory or reveal a hidden file.
- A malformed percent escape gets 400.
- A decoded path with a NUL byte, a backslash or any segment starting with a dot gets 404. That covers
.., encoded traversal such as%2e%2e, and files such as.env. - A file whose real path, after symbolic links, lies outside
public/gets 404. - Methods other than GET and HEAD get 405 with
Allow: GET, HEAD.
Errors reveal a reference, not details
An unhandled exception answers 500 “Something went wrong. Reference: …” with an X-Request-ID header and Cache-Control: no-store. The log records only the request id and the error’s class. Exception messages can hold connection URLs or form values, so they are not logged.
The development error page is compiled only with -D caramel_development, which frappe dev sets. Even then it appears only when CARAMEL_ENV is development. It redacts the values of environment variables named like secrets, passwords, tokens, API keys or database URLs, PostgreSQL URLs, and name=value credentials. A check builds both modes and confirms the page’s code is absent from the production binary.
Caramel checks where a request came from, not what the signed-in user may do. Authorization for each record, rate limits, secret storage and the URLs you write into pages stay in your application.