Cookies, sessions and CORS¶
Cookies, sessions, and what Spindle checks on a request before your route runs.
Reading and setting cookies¶
Declare a cookie once, by name and codec, and use it to read and to set. This page counts your visits:
(* A cookie declared once, and used both to read it and to set it: the page
counts your visits. *)
open Spindle.Syntax
let visits = Spindle.Cookie.named "visits" Spindle.Codec.int
let count seen set_cookie =
let n = Option.value seen ~default:0 + 1 in
set_cookie (Spindle.Cookie.make visits n);
Ok (Printf.sprintf "Visit number %d." n)
let routes =
[
Spindle.get Spindle.Path.root Spindle.Returns.text
(let+ seen = Spindle.Cookie.optional visits
and+ set_cookie = Spindle.set_cookie in
count seen set_cookie);
]
let () =
Spindle.Log.setup ();
Eio_main.run @@ fun env -> Spindle.serve env routes
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
set-cookie: visits=1; Path=/; HttpOnly; SameSite=Lax
...
Visit number 1.
Cookie.optionalreads it (Nonewhen absent) andCookie.requiredrefuses without it. A value that does not parse is400 invalidatcookie.<name>.Cookie.makedefaults toPath=/; HttpOnly; SameSite=Lax. A plain cookie lasts until the browser closes unless you give~max_age_s(seconds); a signed or encrypted one is givenMax-Agefrom its own~max_age(below).Cookie.clearremoves one.- Spindle adds
Secureitself on every host except a loopback one (localhost,127.0.0.1,::1), where Safari would silently drop aSecurecookie sent over plain http. Behind a proxy that ends TLS, list the proxy in~trusted_proxiesso that its word that the browser usedhttpsis believed (below). - The value must be cookie-safe.
Cookie.makeraisesInvalid_argumenton a value with a space, quote, comma, semicolon, backslash or control character. For text that arrived at run time, use theSpindle.Cookie.encodedcodec, which holds any string.
Signed and encrypted cookies¶
Declare a cookie signed (readable, but tamper-proof) or encrypted
(sealed) over a key ring, then read and set it as any other:
match Spindle.Key.of_secret secret with
| Ok key ->
let keys = Spindle.Key.ring key [] in
let prefs =
Spindle.Cookie.encrypted keys ~max_age:Mtime.Span.(30 * day) "prefs" prefs_codec
and who =
Spindle.Cookie.signed keys ~max_age:Mtime.Span.(8 * hour) "who" Spindle.Codec.string
in
...
| Error sentence -> ...
Key.of_secretanswersErrorfor a secret under 32 bytes. Read the secret yourself, at startup.- Rotating a key:
Key.ring newest retiredsigns withnewestand reads with any, so put the new key first and the old one after it. - A cookie that fails its signature, its seal or its age is absent
(
None), never an error. ~max_ageis required, anMtime.Span.t. The server checks it on every read, not only the browser, so a copied cookie stops working when it expires.Cookie.makewrites it asMax-Age, rounded up to whole seconds; passing~max_age_stomakecan make that shorter, never longer.- A signed or encrypted cookie holds any value its codec prints, since
what is sent is encoded as base64url. You need the
encodedcodec only for a plain cookie. Key.sign/Key.verifyandKey.seal/Key.unsealdo the same for anything else, such as a token in a link. They use keys derived apart from the cookies’ keys, so nothing you sign or seal with them can ever be passed off as a cookie.
Sessions¶
Spindle.Session keeps a visit’s data on the server; its cookie holds only
a random id.
let sessions =
Spindle.Session.create ~store:(Spindle.Session.memory ())
~idle:Mtime.Span.(14 * day) ~absolute:Mtime.Span.(90 * day) visit_json
(let+ session = Spindle.Session.cookie sessions
and+ set_cookie = Spindle.set_cookie
and+ now = Spindle.now in
match Spindle.Session.find sessions session ~now with ...)
| Call | What it does |
|---|---|
find |
the session, if it exists and is within both limits |
start |
a new session, and its cookie |
update |
new data under the same id |
renew |
a new id with this data, the old one deleted: call it at sign-in |
close |
deletes it and clears the cookie: sign-out |
sweep |
deletes every expired session; run it periodically (see below) |
Signing in gives the visitor a new session id, whether or not they had a session before, so an id somebody planted earlier signs them into nothing. Signing out deletes the session on the server:
let sign_in sessions id ~set_cookie ~now user =
let started =
match Spindle.Session.find sessions id ~now with
| Ok (Some session) ->
Spindle.Session.renew sessions session ~set_cookie ~now { user }
| Ok None -> Spindle.Session.start sessions ~set_cookie ~now { user }
| Error e -> Error e
in
match started with
| Ok _ -> Ok "Signed in."
| Error e -> Error (Spindle.Session.refusal e)
let sign_out sessions id ~set_cookie ~now =
match Spindle.Session.find sessions id ~now with
| Ok None -> Ok "Signed out."
| Ok (Some session) -> (
match Spindle.Session.close sessions session ~set_cookie with
| Ok () -> Ok "Signed out."
| Error e -> Error (Spindle.Session.refusal e))
| Error e -> Error (Spindle.Session.refusal e)
Session.data session is the data a found session holds. A store that
fails is Error; Session.refusal turns it into a 500 whose detail goes
to the log.
- A session ends
idleafter it was last used, orabsoluteafter it began, whichever comes first. Both areMtime.Span.ts; pass~nowfrom theSpindle.nowdependency. The cookie is namedsessionunless you pass~cookie, and itsMax-Ageisabsolute, rounded up to whole seconds. sweepdeletes the sessions nobody came back to, whichfindnever sees. Run it every so often from a background fiber.Session.memory ()lives in one process and ends with it.Spindle_postgres.Session.store pool ~tablekeeps sessions in your database. The table must exist first: create it in a migration of your own from the SQL thatSpindle_postgres.Session.schema ~tablegives (pool settings).
The client’s IP address¶
Spindle.client is the address of whoever connected. Behind a reverse
proxy, list the proxy in Spindle.serve ~trusted_proxies (addresses or
CIDR ranges, such as 10.0.0.0/8; none by default), and the client is the
address the proxy forwarded for. ~proxy_header names the header it writes:
X_forwarded_for (the default) or Forwarded. The same header gives the
host and whether the browser used https.
CSRF and CORS¶
- Cross-site writes are refused by default. A request by any method but
GET,HEADandOPTIONSthat a browser sent from another site is403 cross_origin. Requests from curl, apps and other servers pass.~trusted_originsallows other sites;~check_origin:falseturns the check off. Spindle.jsonaccepts only JSON: a body whoseContent-Typeis notapplication/json(orapplication/...+json) is415.-
Another site’s page may call your routes only if you allow it:
Spindle.serve env ~cors: (Spindle.Cors.make ~credentials:true (Spindle.Cors.Origins [ "https://app.example.com" ])) routesSpindle answers the preflight itself and names an allowed origin in
Access-Control-Allow-Origin, or answers*underAny; an origin you did not allow is told nothing. An allowed origin also passes the forgery check.~allow_headerslists the headers a request may send:authorizationandcontent-typeunless you say otherwise. A page that sends any other header fails the preflight, and the browser says only that the request was blocked.~exposelists the response headers the page’s script may read (none by default).~max_age_sis how long a browser keeps a preflight’s answer (600 seconds).~routesnarrows the policy to some routes, say your API.
Anywith~credentials:trueraises. UnderAny, a write from another site passes only with no credential the browser attaches by itself: noCookie, and noAuthorizationin a scheme a browser keeps –Basic,Digest,Negotiate,NTLM. A token the page sets itself, such asAuthorization: Bearer ..., is served.