DocsServer features
Security
What the framework guarantees, and what it deliberately leaves to you. Rahti provides transport and rendering safeguards; it does not decide your authorization or your validation.
The trust model
Treat request data, route parameters, RPC payloads, upload metadata, database content and external service responses as untrusted. A value that came out of your own database is not more trustworthy than one that came out of a form — it is the same value, one round trip later.
HTML and script safety
@{…} escapes values in HTML text and in attributes. An Html value bypasses escaping because it is already trusted.
// Escaped, because it came from somewhere.
<p>@{comment.body}</p>
// The explicit trust boundary. Only for markup you produced or independently
// sanitised — and never concatenated from untrusted values.
<div>@{Html::from_raw(sanitised_html)}</div>
// Converting untrusted text to Html by hand.
Html::escape(&comment.body)- prefer ordinary string and value interpolation;
- use
Html::escapewhen converting untrusted text toHtmlby hand; - use
Html::from_rawonly for framework-authored or independently sanitised HTML — it is the explicit trust boundary; - never concatenate untrusted values into a
from_rawstring.
// Inside a script, use RenderJs primitives or Json — Rahti JSON-quotes
// strings and neutralises `<`, so a value cannot close the script.
<script>
const user = @{Json(&profile)};
const seats = @{seats};
</script>
// Never build JavaScript source with format! and inject it as trusted HTML.PulsePoint text bindings escape what they render, and an attribute binding cannot close its quote or introduce an event handler. There is no escape hatch to raw HTML from a {…} binding at all: trusted markup is the server's job.
CSRF and RPC
When an application has page or component RPCs, Rahti issues a random, port-scoped CSRF cookie on page requests. pp.rpc reads it and returns it in the X-CSRF-Token header, and the server requires a matching non-empty token.
- call Rahti RPCs through
pp.rpc, so the wire headers and the token are present; - do not disable or bypass the generated CSRF layer;
- declared RPC parameters are the accepted payload surface — do not add an untyped catch-all without deliberate validation;
- RPC failures return their error messages to the browser, so keep secrets, credentials, internal queries and upstream responses out of
Errormessages.
#[rpc(auth)]
pub async fn delete_note(id: u32) -> Result<()> {
let signed_in = auth::current().expect("`#[rpc(auth)]` answered");
// The session says who; the payload only says which. Checking that the
// row belongs to the caller is the application's job, and nothing else
// does it.
let note = notes::find_owned(signed_in.id, id)
.await?
.ok_or_else(|| Error::new(StatusCode::NOT_FOUND, "no such note"))?;
notes::delete(note.id).await
}Sessions and authorization
- the session cookie is signed, not encrypted — its payload is readable by whoever holds it, so never sign in a password hash, an API token, or anything you would not show the user;
AUTH_SECRETis a credential and lives in the ignored.env. An unset secret means a per-process random key: development only;- the session cookie is
HttpOnly; the CSRF cookie deliberately is not. Do not make the session readable to scripts; - the route guard decides who may load a page;
#[rpc(auth)]decides who may make a call. Protecting the page is not protecting the endpoint; - take the caller's identity from the session, never from an RPC parameter — an
idargument on a mutation lets anyone act as anyone; - sessions are stateless and cannot be revoked before they expire;
- do not add roles, permission strings or OAuth providers to
rahti::auth. Authorization beyond "is there a session" belongs in application code, where the row is in hand.
Redirects and navigation
PulsePoint normalises server-provided redirects and accepts only same-origin targets for SPA handling. External navigation should be explicit, and never derived from an unchecked request parameter. When constructing links or redirect destinations, validate that user-supplied paths remain within the intended origin and route space.
Uploads
// The browser's filename and content type are untrusted metadata.
let name = file.safe_name(); // not file.name()
let path = std::path::Path::new("uploads").join(name); // your directory
// And enforce content rules independently of the claimed MIME type.
if !looks_like_png(&file.bytes().await?) {
return Err(Error::new(StatusCode::BAD_REQUEST, "not a PNG"));
}- never use
name()directly as a path; usesafe_name()and choose a server-controlled directory; - prevent overwrites with application-specific naming or collision handling;
- enforce content rules independently of the claimed MIME type or extension;
- configure request and per-file limits for the deployment;
- stream large files where possible — avoid
bytes()when the size is unbounded; - if you set a spill directory, give it restricted permissions and watch its disk lifecycle.
Rahti cleans its own temporary spilled files when their final owner drops. Files your code explicitly saved become application-owned and need their own retention policy.
Static files
Everything under the configured public directory is intentionally web accessible. Never place secrets, source maps containing sensitive source, private uploads, environment files or server-only configuration there. Treat RAHTI_PUBLIC_DIR as trusted deployment configuration.
Development mode
Development mode injects a reload client and exposes internal dev endpoints. Release builds disable it unless RAHTI_DEV overrides the default — do not enable it in production. Bind the server behind an appropriately configured reverse proxy and TLS boundary.
Database credentials
- the connection string is a credential and never appears in the committed
rahti.config.json; it is read fromDATABASE_URL; .envholds it in development and is gitignored;.env.examplecarries the shape without the value;- a real environment variable always wins over the file, so a deployment cannot be redirected by a stale file on the same machine;
- SeaORM parameterises queries — bind values in any raw SQL rather than formatting them into the string;
- migrations are never applied automatically, so starting a server cannot change a schema.
Supply chain
The optional Tailwind download can execute a fetched standalone binary. Pin its version and populate the platform-specific SHA-256 entry; a configured hash mismatch must remain fatal.
PulsePoint's minified runtime is executable application code served to every visitor. Replace it only with a trusted build of the same asset, install that build in both locations, verify the two are byte-identical, and review the diff. Do not patch minified code directly.
