Security
What Tablo does by default, and what your deployment must provide for those defaults to hold.
Your deployment must provide
- HTTPS. The session, CSRF and notification cookies are
__Host-cookies markedSecure. Browsers accept them over plain HTTP only onlocalhost; anywhere else, without HTTPS, the browser drops them and every form POST fails its CSRF check with 403. - Login rate limiting. Tablo has no rate limiter or account lockout. Limit login attempts at your proxy or firewall.
- Session revocation. Call
auth::revoke_sessions_for_userwhen a password changes or an account is deactivated; see Authentication.
Requests and writes
- Every panel POST, login and logout included, verifies a double-submit CSRF token before touching the database; a missing or wrong token answers 403.
- Delete and bulk-delete POSTs also require the
confirm=1marker the confirmation dialog sends. It prevents accidental deletes; it is not a security boundary. - A form POST with a key the form does not declare answers 400, so a client cannot write a
field the form does not show, such as
roleortenant_id. - Update and delete handlers load the target through the resource’s scoped query inside the write’s transaction and check policy on that row. See Policy.
- Table search escapes
%and_and binds the term as a parameter; no user input is interpolated into SQL.
Authentication
- Passwords are hashed with Argon2id. A login for an unknown email verifies against a dummy hash, so it takes as long as a wrong password, and every failure shows the same message.
- Sessions are stored server-side, keyed by a hash of the cookie’s token, and replaced on login.
- A redirect after login follows
nextonly to a same-origin path.
Responses
- Framing. Every response to a request under the panel prefix, including its 404 and 405
pages, carries
Content-Security-Policy: frame-ancestors 'self', so another site cannot frame the admin. The app’s own routes outside the prefix carry whatever policy the app sets.Panel::frame_ancestors("'self' https://intranet.example")changes the directive, andPanel::without_frame_ancestors()omits it for a proxy that sets its own policy. AContent-Security-Policyyour own handler sets is kept. The router answers three errors before any layer runs, so they carry no header: the 403 for a cross-site request, the 400 for a malformedx-topcoat-identityheader, and the 500 for a panic. - Redirects. Return
Err(redirect(..))(307) from aGETandErr(see_other(..))(303) after a write, so a reload does not repeat it. A redirect raised after streaming started becomes awindow.location.replace. Wrap a layout’sSlotinerror_boundaryfor a branded error page. - Notifications are one-time flash cookies set on the redirect after a write and consumed by the next page, so a reload does not show them again.
Uploaded files
- A file field stores only a value that came from a file part: the uploader’s answer, the stored value on an untouched edit, or nothing when cleared. A path typed into the form is never stored. See File uploads.
- A stored value renders as a link only when it is a root-relative path (
/…, not//host) or anhttp(s)URL; anything else, such asjavascript:, renders as text. - Files served with
Panel::serve_dirshare the panel’s origin, so every file response carriesX-Content-Type-Options: nosniff, a sandboxingContent-Security-Policy, andContent-Disposition: attachmentunless the file is a common image, audio or video type or plain text. An uploaded HTML or SVG file therefore downloads instead of running script. The policy is fixed; serve active documents from another origin. - Served directories are public: the auth gate does not cover them.