diff --git a/CHANGES b/CHANGES index cb081f52f..b4f5318f8 100644 --- a/CHANGES +++ b/CHANGES @@ -1,4 +1,17 @@ ==== 8.0.0~dev === + * Security: the limit (maximum number of simultaneous + connections) is now enforced again. It throttles the accept loop instead + of being silently ignored. + * Security: error responses no longer include the raw OCaml exception in + their body. Clients now receive the generic HTTP status reason phrase; + the exception details remain in the logs. + * Security: the command pipe is now created with mode 0o600 instead of + 0o660, so that local users in the server's group can no longer send it + control commands (shutdown, reload, ...). + * Security (TLS): the HTTPS server now requires TLS 1.2 at minimum (TLS 1.0 + and 1.1 are deprecated) and prefers the server's cipher order. The + , and entries of , previously parsed but + ignored, are now applied to the TLS context. * One-command serve mode: "ocsigenserver ./public" (or "ocsigenserver --serve ./public --port 8000 --directory-listing") now serves a directory of static files without any configuration file or diff --git a/doc/config.mld b/doc/config.mld index 2fba53e32..14f087f08 100644 --- a/doc/config.mld +++ b/doc/config.mld @@ -101,9 +101,6 @@ Here is a simple example: /var/lib/ocsigenserver /var/run/ocsigenserver/command - www-data - www-data - utf-8 @@ -147,9 +144,6 @@ exist. To create it run [psql] and execute [CREATE TABLE ocsipersist;]. /home/toto/var/lib/ocsigenserver /home/toto/var/run/ocsigenserver_command - toto - toto - utf-8 @@ -233,10 +227,11 @@ If you want to use HTTPS, you need to specify a certificate to use, and a privat v} -Use the tools provided by openssl to create a 1024-bit private key to use when creating your CA.: +Use the tools provided by openssl to create a private key. Use at least a +2048-bit RSA key (4096 bits is common today), or an elliptic-curve key: {v - openssl genrsa -des3 -out privkey.pem 1024 + openssl genrsa -des3 -out privkey.pem 4096 v} To create a master certificate based on this key, to use when signing other certificates: @@ -251,6 +246,43 @@ If you don't want to be asked for a password at start-up, you can uncrypt the pr openssl rsa -in privkey.pem -out privkey-unsec.pem v} +The TLS context requires {b TLS 1.2 at minimum} (TLS 1.0 and 1.1 are +deprecated, RFC 8996) and prefers the server's cipher order. You can further +tune the context inside []: + +{v + + path_to/cert.pem + path_to/privkey.pem + ECDHE+AESGCM:ECDHE+CHACHA20 + path_to/dhparams.pem + prime256v1 + +v} + +[] is an OpenSSL cipher string, [] a file of +Diffie-Hellman parameters, and [] a named elliptic curve. + +If you serve both HTTP and HTTPS and want to force HTTPS, redirect plain HTTP +requests to HTTPS using the {{!page-redirectmod}Redirectmod} extension guarded +by the [] condition of {{!page-accesscontrol}Accesscontrol} (both +extensions must be loaded): + +{v + + + + + + + + +v} + +Note: HTTP Strict Transport Security (HSTS) is not enabled automatically. +Once you serve only over HTTPS, add a [Strict-Transport-Security] response +header (for instance with the {{!page-outputfilter}Outputfilter} extension). + {3 [] : log files directory} @@ -295,16 +327,20 @@ v} {3 [] and [] : user who runs the server} -If the server is launched by root, it will change itself the user and group for the process, for security reasons. Create a group and a user for Ocsigenserver or use an existing user of your system (e.g. [www-data]). +{b Deprecated and ignored.} Ocsigenserver no longer drops privileges: these +tags are accepted for backward compatibility but have {b no effect}, and the +server logs a warning if they are present. -Example : +{b Do not launch the server as root.} Run it directly as a dedicated +unprivileged user (e.g. [www-data]). To serve a privileged port (80 or 443) +as a non-root user, use one of: +- a reverse proxy (nginx, HAProxy, Caddy, ...) terminating ports 80/443 and + forwarding to Ocsigenserver on a high port (recommended, see the [] + note above); +- the Linux capability [cap_net_bind_service] on the executable + ([setcap 'cap_net_bind_service=+ep' ...]); +- a firewall/port redirection from 80/443 to a high port. - - -{v -www-data -www-data -v} {3 [] : default charset for pages} @@ -396,22 +432,24 @@ v} {3 [] : Timeout for connections} -Written in seconds. If the client does not say anything during that amount of time, the connection will be closed. Example: - - +Written in seconds. Example: {v 20 v} -{3 [] : Timeout for Keep-Alive} - -Amount of time (in seconds) the server will wait for subsequent requests on a persistent connection. Example: +{b Limitation.} This option (and its aliases [] and +[]) is currently parsed but {b not enforced}: the cohttp-based +connection handling does not yet apply a per-connection idle/read timeout. As +a consequence Ocsigenserver does {b not} protect against slow-client +("Slowloris") attacks on its own, where a client opens many connections and +sends the request very slowly to exhaust resources. - -{v -10 -v} +{b Recommendation.} For an Internet-facing deployment, place a reverse proxy +(nginx, HAProxy, Caddy, ...) in front of Ocsigenserver. The proxy enforces +read/idle timeouts, buffers slow clients, terminates TLS and absorbs malformed +requests. The [] limit above does cap the number of +simultaneous connections, which mitigates connection exhaustion. {3 [] : Timeout for graceful shutdown {e From version 1.3}} diff --git a/src/server/Ocsigen/ocsigen_cohttp.ml b/src/server/Ocsigen/ocsigen_cohttp.ml index d6381e9aa..cb7821956 100644 --- a/src/server/Ocsigen/ocsigen_cohttp.ml +++ b/src/server/Ocsigen/ocsigen_cohttp.ml @@ -77,32 +77,38 @@ let handler ~ssl ~address ~port ~connector (flow, conn) request body = fmt ("Got exception while handling request." ^^ "@\n%s") (Printexc.to_string exn)); - let headers, ret_code = + let headers, ret_code, explicit_msg = match exn with | Ocsigen_http_error (cookies_to_set, code) -> let headers = Cookie.serialize cookies_to_set (Cohttp.Header.init ()) in - Some headers, code + Some headers, code, None | Ocsigen_base.Ocsigen_stream.Interrupted Ocsigen_base.Ocsigen_stream.Already_read -> - None, `Internal_server_error - | Unix.Unix_error (Unix.EACCES, _, _) -> None, `Forbidden - | Ext_http_error (code, _, headers) -> headers, code - | Ocsigen_base.Lib.Ocsigen_Bad_Request -> None, `Bad_request + None, `Internal_server_error, None + | Unix.Unix_error (Unix.EACCES, _, _) -> None, `Forbidden, None + | Ext_http_error (code, msg, headers) -> headers, code, msg + | Ocsigen_base.Lib.Ocsigen_Bad_Request -> None, `Bad_request, None | Ocsigen_base.Lib.Ocsigen_Request_too_long -> - None, `Request_entity_too_large + None, `Request_entity_too_large, None | exn -> Logs.err ~src:section (fun fmt -> fmt ("Error while handling request." ^^ "@\n%s") (Printexc.to_string exn)); - None, `Internal_server_error + None, `Internal_server_error, None in let body = - match ret_code with - | `Not_found -> "Not Found" - | _ -> Printexc.to_string exn + (* Never leak the OCaml exception (constructor names, file paths, ...) + to the client: the exception detail stays in the logs (above). The + client only gets the generic status reason phrase, or the explicit + message an extension chose to expose through [Ext_http_error]. *) + match explicit_msg with + | Some msg -> msg + | None -> + Cohttp.Code.reason_phrase_of_code + (Cohttp.Code.code_of_status (ret_code :> Cohttp.Code.status_code)) in Response.respond_error ?headers ~status:(ret_code :> Cohttp.Code.status_code) @@ -169,7 +175,24 @@ let shutdown timeout = in ignore (Lwt.pick [process (); stop] >>= fun () -> exit 0 : unit Lwt.t) +(* Harden the TLS server context used by Conduit/OpenSSL. Conduit already + disables SSLv2/SSLv3 on its default server context; we additionally require + TLS 1.2 at minimum (TLS 1.0 and 1.1 are deprecated, RFC 8996) and let the + server choose the cipher. The , and entries of the + configuration, until now parsed but never applied, are honoured. *) +let harden_tls_context () = + let ctx = Conduit_lwt_unix_ssl.Server.default_ctx in + Ssl.set_min_protocol_version ctx Ssl.TLSv1_2; + Ssl.honor_cipher_order ctx; + match Config.get_ssl_info () with + | None -> () + | Some {Config.ssl_ciphers; ssl_dhfile; ssl_curve; _} -> + Option.iter (Ssl.set_cipher_list ctx) ssl_ciphers; + Option.iter (Ssl.init_dh_from_file ctx) ssl_dhfile; + Option.iter (Ssl.init_ec_from_named_curve ctx) ssl_curve + let service ?ssl ~address ~port ~connector () = + (match ssl with Some _ -> harden_tls_context () | None -> ()); let tls_own_key = match ssl with | Some (crt, key, Some password) -> diff --git a/src/server/Ocsigen/server.ml b/src/server/Ocsigen/server.ml index 5008cf87c..05d75d01e 100644 --- a/src/server/Ocsigen/server.ml +++ b/src/server/Ocsigen/server.ml @@ -238,9 +238,14 @@ let main config = true with Unix.Unix_error _ -> ( try - let umask = Unix.umask 0 in - Unix.mkfifo commandpipe 0o660; - ignore (Unix.umask umask : int); + (* The command pipe is a control channel (shutdown, reload, + clearcache, ...). Now that / no longer drop + privileges, the server runs as the launching user and owns the + pipe, so only that user should be able to send commands: hence + 0o600. (Group administration via the pipe is no longer + supported.) A 0o600 mode has no group/other bits, so it is + unaffected by the umask and there is no need to force it to 0. *) + Unix.mkfifo commandpipe 0o600; Logs.warn ~src:section (fun fmt -> fmt "Command pipe created"); true with e -> @@ -283,7 +288,7 @@ let main config = (if with_commandpipe then let pipe = - Unix.(openfile commandpipe [O_RDWR; O_NONBLOCK; O_APPEND]) 0o660 + Unix.(openfile commandpipe [O_RDWR; O_NONBLOCK; O_APPEND]) 0o600 |> Lwt_unix.of_unix_file_descr |> Lwt_io.(of_fd ~mode:input) in @@ -315,6 +320,13 @@ let main config = >>= f in ignore (f () : 'a Lwt.t)); + (* Enforce the maximum number of simultaneous connections: conduit + throttles its accept loop (across all listening sockets, as file + descriptors are a global resource) while the number of active + connections exceeds this cap. Without this call the configured limit + would have no effect. *) + Conduit_lwt_server.set_max_active + (Config.get_max_number_of_connections ()); Lwt.join (List.map (fun (address, port) -> diff --git a/test/security-tls.t/dune b/test/security-tls.t/dune new file mode 100644 index 000000000..bec17da00 --- /dev/null +++ b/test/security-tls.t/dune @@ -0,0 +1,3 @@ +(executable + (name test) + (libraries ocsigenserver)) diff --git a/test/security-tls.t/dune-project b/test/security-tls.t/dune-project new file mode 100644 index 000000000..544666a2c --- /dev/null +++ b/test/security-tls.t/dune-project @@ -0,0 +1 @@ +(lang dune 3.18) diff --git a/test/security-tls.t/run.t b/test/security-tls.t/run.t new file mode 100644 index 000000000..e8b43cbf8 --- /dev/null +++ b/test/security-tls.t/run.t @@ -0,0 +1,23 @@ +F5: the HTTPS server must require TLS 1.2 at minimum (TLS 1.0/1.1 deprecated). +Self-contained POSIX sh. + + $ mkdir -p log data + $ openssl req -x509 -newkey rsa:2048 -nodes -keyout privkey.pem \ + > -out cert.pem -days 2 -subj /CN=localhost >/dev/null 2>&1 + $ dune build ./test.exe 2>&1 + $ dune exec -- ./test.exe >server.log 2>&1 & + $ trap 'echo shutdown > local.cmd 2>/dev/null; wait' EXIT + $ i=0; while [ ! -e local.cmd ]; do + > i=$((i+1)); [ $i -gt 200 ] && break; sleep 0.05; done + $ i=0; while ! curl -sk --tls-max 1.2 https://127.0.0.1:8453/ >/dev/null 2>&1; do + > i=$((i+1)); [ $i -gt 200 ] && break; sleep 0.05; done + +A TLS 1.2 client succeeds: + + $ curl -sk --tlsv1.2 --tls-max 1.2 -o /dev/null -w '%{http_code}\n' https://127.0.0.1:8453/ + 200 + +A TLS 1.1 client is refused (curl returns a non-zero exit code): + + $ curl -sk --tls-max 1.1 https://127.0.0.1:8453/ >/dev/null 2>&1 && echo accepted || echo rejected + rejected diff --git a/test/security-tls.t/test.ml b/test/security-tls.t/test.ml new file mode 100644 index 000000000..72e906c74 --- /dev/null +++ b/test/security-tls.t/test.ml @@ -0,0 +1,25 @@ +(* F5: the HTTPS server must require TLS 1.2 at minimum. This starts an HTTPS + listener with a self-signed certificate (generated by the cram test). The + handler is irrelevant: the test only checks the TLS handshake. *) + +let respond _vh _config_info _path _request_state = + Lwt.return + (Ocsigen.Extensions.Ext_found + (fun () -> + Lwt.return + (Ocsigen.Response.make + (Cohttp.Response.make ~status:`OK ())))) + +let () = + Ocsigen.Server.start ~ports:[] + ~ssl_ports:[ (`All, 8453) ] + ~ssl_info: + (Some + { Ocsigen.Config.ssl_certificate = Some "cert.pem" + ; ssl_privatekey = Some "privkey.pem" + ; ssl_ciphers = None + ; ssl_dhfile = None + ; ssl_curve = None }) + ~logdir:"log" ~datadir:"data" ~uploaddir:None ~usedefaulthostname:true + ~command_pipe:"local.cmd" ~default_charset:(Some "utf-8") + [ Ocsigen.Server.host [ respond ] ] diff --git a/test/security.t/dune b/test/security.t/dune new file mode 100644 index 000000000..bec17da00 --- /dev/null +++ b/test/security.t/dune @@ -0,0 +1,3 @@ +(executable + (name test) + (libraries ocsigenserver)) diff --git a/test/security.t/dune-project b/test/security.t/dune-project new file mode 100644 index 000000000..544666a2c --- /dev/null +++ b/test/security.t/dune-project @@ -0,0 +1 @@ +(lang dune 3.18) diff --git a/test/security.t/run.t b/test/security.t/run.t new file mode 100644 index 000000000..bd3f4543b --- /dev/null +++ b/test/security.t/run.t @@ -0,0 +1,26 @@ +Security hardening checks: command pipe permissions (F8) and error-body +sanitisation (F4). Self-contained (POSIX sh, no bash helper) so it runs +regardless of the cram shell. + + $ mkdir -p log data + $ dune build ./test.exe 2>&1 + $ dune exec -- ./test.exe >server.log 2>&1 & + $ trap 'echo shutdown > local.cmd 2>/dev/null; wait' EXIT + $ i=0; while [ ! -e local.sock ] || [ ! -e local.cmd ]; do + > i=$((i+1)); [ $i -gt 200 ] && break; sleep 0.05; done + +F8: the command pipe is created with mode 0o600, so that only the server's own +user can send it control commands. + + $ stat -c '%a' local.cmd + 600 + +F4: a handler error returns the generic HTTP status reason phrase, never the +OCaml exception (the word "secret" from the exception must not appear). + + $ curl --unix-socket local.sock -s -o /dev/null -w '%{http_code}\n' http://x/anything + 500 + $ curl --unix-socket local.sock -s http://x/anything + Error: Internal Server Error + $ curl --unix-socket local.sock -s http://x/anything | grep -c secret || true + 0 diff --git a/test/security.t/test.ml b/test/security.t/test.ml new file mode 100644 index 000000000..3ca64dae3 --- /dev/null +++ b/test/security.t/test.ml @@ -0,0 +1,17 @@ +(* End-to-end checks for the security hardening: + - F4: a handler error must not leak the OCaml exception in the response + body; the client gets the generic HTTP status reason phrase. + - F8: the command pipe must be created with mode 0o600. + + The [boom] instruction intercepts every request and fails with an + exception carrying a "secret" message that must never reach the client. *) + +let boom _vh _config_info _path _request_state = + Lwt.fail (Failure "secret internal detail that must not leak to the client") + +let () = + Ocsigen.Server.start + ~ports:[ (`Unix "./local.sock", 0) ] + ~logdir:"log" ~datadir:"data" ~uploaddir:None ~usedefaulthostname:true + ~command_pipe:"local.cmd" ~default_charset:(Some "utf-8") + [ Ocsigen.Server.host [ boom ] ]