Harden request routing and static files
ober
3f5a2f7c00dbf44cf0f88c0dbcafda93b7cbf430
--- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,21 +13,18 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Install build tools - run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates git ripgrep pkg-config + run: sudo apt-get update && sudo apt-get install -y build-essential curl ca-certificates git openssh-client ripgrep pkg-config - name: Install jerbuild - run: | - set -eux - curl -fsSL "https://github.com/jerboa-lang/jerboa/releases/download/${JERBOA_VERSION}/jerbuild-linux-x86_64" -o /usr/local/bin/jerbuild - chmod +x /usr/local/bin/jerbuild - env: - JERBOA_VERSION: v0.2.3 + run: support/install-verified-jerbuild.sh /tmp/jerbuild - name: Verify - run: JERBUILD=/usr/local/bin/jerbuild make verify + run: JERBUILD=/tmp/jerbuild make verify - name: Release evidence - run: JERBUILD=/usr/local/bin/jerbuild make release-evidence + run: JERBUILD=/tmp/jerbuild make release-evidence --- a/README.md +++ b/README.md @@ -45,11 +45,18 @@ falls back to `jerboa` on hosts that do not have an adjacent source checkout. The framework now HTML-escapes `render-string` template substitutions by default, rejects CR/LF in generated headers and cookies, signs sessions with constant-work comparison, uses secure session cookies by default, bounds static -file reads, rejects static traversal paths, rejects `Transfer-Encoding` plus -`Content-Length`, rejects unsupported method overrides, and records a 512-case +file reads through descriptor-relative no-follow opens, rejects static traversal +and link escapes, binds loopback by default, enforces finite connection, +per-source, idle, header, and body budgets, hides exception details by default, +rejects `Transfer-Encoding` plus `Content-Length`, rejects unsupported method +overrides, and records a 512-case route/cookie/template/static security corpus plus a 2048-request local bounded handler soak. +CI deliberately fails closed at the Jerbuild installation step until the Jerboa +producer publishes a signed release manifest and this repository provisions the +matching signer and digest in `support/jerbuild-bootstrap.lock`. + ## Example Binary ```sh --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,12 @@ No public production-support commitment exists yet. Security-sensitive releases - Template substitution through `render-string` and `render-file-template` is HTML-escaped by default. `render-raw-string` is explicit trusted-content behavior. - Response headers and generated cookies reject CR/LF. - Session cookies are HMAC-SHA256 signed, verified with constant-work comparison, `HttpOnly`, `SameSite=Lax`, and `Secure` by default. -- Static files must stay inside the configured public directory and are capped by `static-max-bytes`. +- Static files are opened beneath a retained public-directory descriptor without + following symlinks or accepting hard links, and are capped by `static-max-bytes`. +- The plain HTTP listener binds `127.0.0.1` by default and applies finite global, + per-source, accept-rate, idle, header, and body budgets before worker creation. +- Error details are hidden in the production-safe default configuration. Explicit + development diagnostics are HTML-escaped; raw dumps remain local-only. - Requests with both `Transfer-Encoding` and `Content-Length` are rejected before routing. - Method override accepts only `PUT`, `PATCH`, and `DELETE`. - The example server requires `JERBOA_SINATRA_SESSION_SECRET`; do not commit demo or production secrets. @@ -45,6 +50,8 @@ No public production-support commitment exists yet. Security-sensitive releases web security corpus. - Proxy trust, TLS/mTLS ingress, application authentication, authorization, rate limits, logging, and deployment sandboxing remain application responsibilities. - External review is still required before public production support. +- Hosted CI bootstrap remains blocked until an authenticated Jerbuild release + manifest, digest, and pinned signer identity are available. - Target proof files must not include secrets, tokens, private request or response bodies, logs, or host-private data. --- a/docs/release-evidence.md +++ b/docs/release-evidence.md @@ -12,7 +12,8 @@ The target writes `dist/release-evidence/` with: - `test.log` from the framework test suite. - `import-check.log` from loading all public modules. - `web-security-smoke.log` and `web-security-smoke/result.txt` from template, - cookie, request-smuggling, method-override, and static-file negative tests. + cookie, request-smuggling, method-override, escaped-error, and descriptor-safe + static-file negative tests. - `web-security-corpus.log` and `web-security-corpus/result.txt` from the 512 case route, cookie, template, and static-file deterministic security corpus. - `soak-evidence.log` and `soak-evidence/result.txt` from the 2048 request @@ -46,6 +47,11 @@ hosted load/soak evidence for at least one production-style application, proxy/TLS deployment review, and external review of the web API and deployment assumptions. +The hosted workflow uses a full-commit action pin and a signed-manifest Jerbuild +installer. Its bootstrap lock is intentionally `blocked` until an upstream +signed manifest and a repository-pinned signer/digest exist; an unsigned direct +release download is never treated as release evidence. + `make target-evidence` writes `dist/target-evidence/status.txt`. Without a reviewed proof file, target production items are recorded as `blocked-not-run`; this is intentional local evidence and not a production pass. Set --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -23,6 +23,9 @@ termination, logging policy, and data-retention policy. - Template variables are hostile unless produced by trusted application code. - Static file roots are trusted configuration; request paths selecting files are hostile. +- Bind addresses and listener budgets are trusted configuration. The default is + loopback with finite global, per-source, accept-rate, idle, header, and body + limits; broad exposure requires an explicit numeric address. - The built-in TLS helper delegates to Jerboa `std net httpsd`. Production services should still prefer a reviewed ingress/proxy policy with current TLS configuration and request limits. @@ -37,7 +40,10 @@ termination, logging policy, and data-retention policy. not. - Reject CR/LF in response headers and generated cookies. - Serve static files only from a configured public directory and enforce the - default size cap unless the application has a separate resource-control plan. + descriptor-relative no-follow policy and default size cap unless the + application has a separate resource-control plan. +- Keep production-safe errors and loopback binding as defaults. Development + exception details require an explicit opt-in and remain escaped. - Keep broad direct Chez imports, bare `read`, generated artifacts, and embedded placeholder secrets out of release-visible source. - Keep the deterministic route, cookie, template, and static-file corpus in --- a/docs/web-security.md +++ b/docs/web-security.md @@ -20,11 +20,22 @@ deployments should leave it enabled and serve through HTTPS. ## Static Files -Static file lookup rejects raw and encoded traversal shapes before path -expansion, normalizes the configured public directory and the requested -candidate path, then requires the candidate to be the public directory or a true -descendant path. The default static response reader caps file reads at 8 MiB via -`static-max-bytes`. +Static file lookup rejects raw and encoded traversal shapes, pins the configured +public directory, and opens every descendant relative to that descriptor with +no-follow semantics. Symlinks, hard links, non-regular files, and identity +changes are rejected. Contents are captured into an opaque token before routing +continues, so a later path swap cannot redirect the response. The default cap is +8 MiB via `static-max-bytes`. + +## Listener And Errors + +Plain HTTP binds loopback unless the application explicitly supplies a numeric +IPv4 address. The shared backend enforces finite global and per-source +connection limits, a per-source accept rate, an idle socket deadline, and total +header/body deadlines before allocating a worker. Startup reports the +kernel-confirmed endpoint. Production-safe error responses are the default; +development exception rendering requires `show-exceptions` and HTML-escapes the +diagnostic. `dump-errors` writes only to the local error port. ## Proxy And TLS Trust --- a/scripts/security-check.sh +++ b/scripts/security-check.sh @@ -18,6 +18,9 @@ required=( scripts/web-security-smoke.sh scripts/soak-evidence.sh support/import-check.ss + support/install-verified-jerbuild.sh + support/jerboa-release-signers + support/jerbuild-bootstrap.lock support/soak-evidence.ss support/web-security-corpus.ss support/web-security-smoke.ss @@ -75,7 +78,10 @@ grep -q 'constant-time-string=?' sinatra/session.ss grep -q 'session-secure' sinatra/app.ss grep -q 'safe-cookie-name' sinatra/cookies.ss grep -q 'safe-header-name' sinatra/response.ss -grep -q 'path-descendant?' sinatra/static.ss +grep -q 'secure-directory-open-strict' sinatra/static.ss +grep -q 'secure-read-file' sinatra/static.ss +grep -q 'html-escape details' sinatra/errors.ss +grep -q 'max-connections-per-ip' sinatra/dsl.ss grep -q 'smuggling-risk?' sinatra/handler.ss grep -q 'validated-method-override' sinatra/handler.ss grep -q 'JERBOA_SINATRA_SESSION_SECRET' example.ss @@ -91,6 +97,14 @@ grep -q 'hosted_soak_status: blocked-not-run' docs/release-evidence.md grep -q 'target_evidence_status: local-production-proof-recorded' docs/release-evidence.md grep -q 'JSINATRA_TARGET_PROOF_FILE' docs/release-evidence.md grep -q 'target-evidence' Makefile +grep -q '^status=blocked-awaiting-authenticated-upstream-release$' support/jerbuild-bootstrap.lock +grep -q 'ssh-keygen -Y verify' support/install-verified-jerbuild.sh +grep -Eq 'actions/checkout@[0-9a-f]{40}' .github/workflows/ci.yml +grep -q 'persist-credentials: false' .github/workflows/ci.yml +if grep -q 'releases/download/.*/jerbuild' .github/workflows/ci.yml; then + echo "CI contains an unauthenticated direct Jerbuild download" >&2 + exit 1 +fi if grep -R -n -E 'super-secret|change-in-production|password|api[_-]?key' \ --include='*.ss' --include='*.md' sinatra sinatra.ss example.ss README.md SECURITY.md docs support 2>/dev/null; then --- a/scripts/web-security-smoke.sh +++ b/scripts/web-security-smoke.sh @@ -14,6 +14,8 @@ mkdir -p "$fixture_dir/public" printf 'public ok\n' > "$fixture_dir/public/ok.txt" printf 'not public\n' > "$fixture_dir/secret.txt" printf '0123456789\n' > "$fixture_dir/public/big.txt" +ln -s ../secret.txt "$fixture_dir/public/outside-link.txt" +ln -s .. "$fixture_dir/public/linked-dir" JERBOA_SINATRA_STATIC_FIXTURE="$fixture_dir/public" \ $jerbuild exec --libdirs "$libdirs" support/web-security-smoke.ss \ --- a/sinatra/app.ss +++ b/sinatra/app.ss @@ -36,11 +36,17 @@ ("static" #t) ("static-max-bytes" 8388608) ("logging" #t) - ("show-exceptions" #t) - ("dump-errors" #t) + ("show-exceptions" #f) + ("dump-errors" #f) + ("max-connections" 128) + ("max-connections-per-ip" 16) + ("accepts-per-second-per-ip" 32) + ("idle-timeout-ms" 5000) + ("header-timeout-ms" 15000) + ("body-timeout-ms" 30000) ("method-override" #f) ("default-content-type" "text/html; charset=utf-8") - ("environment" "development"))) + ("environment" "production"))) ;; App is a hash-table holding all registrations. ;; Using a hash-table rather than defclass to keep things simple @@ -54,7 +60,7 @@ (hash-put! app 'not-found-handler #f) (hash-put! app 'settings (hash-copy default-settings)) (hash-put! app 'middleware '()) - (hash-put! app 'environment "development") + (hash-put! app 'environment "production") app)) ;; Global default app for classic mode @@ -126,4 +132,4 @@ (def (app-error-handlers app) (hash-ref app 'error-handlers)) (def (app-environment app) - (or (app-setting app "environment") "development")) + (or (app-setting app "environment") "production")) --- a/sinatra/dsl.ss +++ b/sinatra/dsl.ss @@ -154,8 +154,23 @@ (let ((port (or port (app-setting the-app "port") 4567)) (bind (or bind (app-setting the-app "bind") "127.0.0.1"))) (let* ((handler-fn (sinatra-handler the-app)) - (srv (httpd-start port handler-fn))) - (displayln (format "== Sinatra has taken the stage on port ~a ==" port)) + (config + (httpd-config + 'port port + 'address bind + 'max-connections (app-setting the-app "max-connections") + 'max-connections-per-ip + (app-setting the-app "max-connections-per-ip") + 'accepts-per-second-per-ip + (app-setting the-app "accepts-per-second-per-ip") + 'idle-timeout-ms (app-setting the-app "idle-timeout-ms") + 'header-timeout-ms (app-setting the-app "header-timeout-ms") + 'body-timeout-ms (app-setting the-app "body-timeout-ms"))) + (srv (httpd-start config handler-fn))) + (displayln + (format "== Sinatra listening on ~a:~a ==" + (httpd-listen-address srv) + (httpd-listen-port srv))) srv))) (def (run-https! (the-app default-app) --- a/sinatra/errors.ss +++ b/sinatra/errors.ss @@ -1,11 +1,15 @@ (import (sinatra response) (sinatra helpers) (sinatra context) - (sinatra app)) + (sinatra app) + (sinatra template)) (export handle-not-found handle-error) +(def (exception-text exn) + (with-output-to-string (lambda () (display-exception exn)))) + ;; Handle 404 - not found (def (handle-not-found app sres) (sinatra-response-status-set! sres 404) @@ -31,11 +35,23 @@ ;; Default error handling (begin (sinatra-response-status-set! sres 500) - (let ((environment (app-environment app))) - (if (string=? environment "development") - (sinatra-response-body-set! sres - (string-append "<h1>Error</h1><pre>" - (with-output-to-string (lambda () (display-exception exn))) - "</pre>")) - (sinatra-response-body-set! sres - "<h1>Internal Server Error</h1>")))))))) + (let ((environment (app-environment app)) + (details (exception-text exn))) + ;; Raw diagnostics are local-only and require explicit opt-in. + (when (app-setting app "dump-errors") + (display details (current-error-port)) + (newline (current-error-port))) + (if (and (string=? environment "development") + (app-setting app "show-exceptions")) + (begin + (sinatra-response-content-type-set! + sres "text/html; charset=utf-8") + (sinatra-response-body-set! sres + (string-append "<h1>Error</h1><pre>" + (html-escape details) + "</pre>"))) + (begin + (sinatra-response-content-type-set! + sres "text/html; charset=utf-8") + (sinatra-response-body-set! sres + "<h1>Internal Server Error</h1>"))))))))) --- a/sinatra/filters.ss +++ b/sinatra/filters.ss @@ -28,4 +28,5 @@ ;; Check if a pattern matches the given path. (def (filter-matches? pattern path) (let-values (((rx _names) (compile-route-pattern pattern))) - (and (pregexp-match rx path) #t))) + ;; Filter patterns are trusted configuration compiled by the route DSL. + (and (pregexp-match rx path) #t))) ; jerboa-security: suppress scheme-dynamic-pregexp-redos --- a/sinatra/handler.ss +++ b/sinatra/handler.ss @@ -303,7 +303,7 @@ (interpret-list-result result sres)) ((sinatra-void? result) (void)) ((bytevector? result) - (sinatra-response-body-set! sres (utf8->string result))))) + (sinatra-response-body-set! sres result)))) ;; Interpret a list result: [status body] or [status headers body] (def (interpret-list-result result sres) --- a/sinatra/helpers-test.ss +++ b/sinatra/helpers-test.ss @@ -83,6 +83,6 @@ (check (sinatra-response-header sres "Cache-Control") => "public, max-age=3600")))) - (test-case "development? returns true by default" - (check (development?) => #t)) + (test-case "production? returns true by default" + (check (production?) => #t)) )) --- a/sinatra/helpers.ss +++ b/sinatra/helpers.ss @@ -141,7 +141,7 @@ ;; Environment helpers (def (env) (let ((a (current-app))) - (if a (app-environment a) "development"))) + (if a (app-environment a) "production"))) (def (development?) (string=? (env) "development")) --- a/sinatra/response.ss +++ b/sinatra/response.ss @@ -16,7 +16,7 @@ (defstruct sinatra-response (status ;; integer, default 200 hdrs ;; hash-table of header name -> value - body ;; string or #f + body ;; string, bytevector, or #f cookies ;; list of Set-Cookie strings halted?) ;; boolean transparent: #t) @@ -102,12 +102,13 @@ (let* ((status (sinatra-response-status sres)) (headers-alist (sinatra-response-headers-alist sres)) (body (sinatra-response-body sres)) - (body-str (cond + (body-out (cond ((string? body) body) - ((bytevector? body) (utf8->string body)) + ((bytevector? body) body) + (body body) (else #f)))) ;; Ensure Content-Type is set (unless (sinatra-response-content-type sres) (set! headers-alist (cons (cons "Content-Type" "text/html; charset=utf-8") headers-alist))) - (http-respond status headers-alist body-str))) + (http-respond status headers-alist body-out))) --- a/sinatra/route.ss +++ b/sinatra/route.ss @@ -46,7 +46,8 @@ ;; Done — close anchor and compile (begin (display "$" rx) - (values (pregexp (get-output-string rx)) + ;; The route DSL emits only escaped literals and fixed capture fragments. + (values (pregexp (get-output-string rx)) ; jerboa-security: suppress scheme-dynamic-pregexp-redos (reverse names))) (let ((ch (string-ref pattern i))) (cond --- a/sinatra/static.ss +++ b/sinatra/static.ss @@ -1,4 +1,5 @@ (import (std net httpd) + (std os secure-output) (sinatra mime) (sinatra app)) @@ -27,31 +28,62 @@ (static-path-contains? path "%5C") (static-path-contains? path "%00"))) -;; Check if a file exists in the public folder for the given request path. -;; Returns the full file path or #f. +;; Resolve and read a public file beneath a retained directory descriptor. +;; The returned opaque token contains the already-opened contents, so serving it +;; cannot be redirected by a symlink swap after this check. (def (try-static-file app path) (try (and (not (unsafe-static-request-path? path)) (let* ((public-dir (or (app-setting app "public-folder") "./public")) - (normalized-public (path-normalize public-dir)) - ;; Build the full path: public-dir + request path - (full-path (path-expand - (string-append "." path) - public-dir)) - (normalized-full (path-normalize full-path))) - ;; Security: ensure resolved path is within public-dir (prevent traversal) - (and (path-descendant? normalized-public normalized-full) - (file-exists? normalized-full) - (not (file-directory? normalized-full)) - normalized-full))) + (max-bytes (or (app-setting app "static-max-bytes") 8388608)) + (relative-path (static-relative-path path))) + (and (> (string-length relative-path) 0) + (let ((root (secure-directory-open-strict public-dir))) + (dynamic-wind + (lambda () #f) + (lambda () + (let ((entry (secure-entry-info root relative-path))) + (and (eq? (secure-entry-kind entry) 'file) + (= (secure-entry-links entry) 1) + (<= (secure-entry-size entry) max-bytes) + (let-values (((contents identity) + (secure-read-file + root relative-path max-bytes))) + (and (= (secure-entry-device entry) + (secure-entry-device identity)) + (= (secure-entry-inode entry) + (secure-entry-inode identity)) + (vector 'secure-static-file + relative-path contents)))))) + (lambda () (secure-directory-close root))))))) (catch (e) #f))) +(def (static-relative-path path) + (let loop ((at 0)) + (if (and (< at (string-length path)) + (char=? (string-ref path at) #\/)) + (loop (+ at 1)) + (substring path at (string-length path))))) + ;; Serve a static file as a Jerboa httpd response. -(def (serve-static-file full-path . maybe-max-bytes) - (let* ((ct (or (mime-type-for full-path) "application/octet-stream")) +(def (serve-static-file file . maybe-max-bytes) + (let* ((secure-token? + (and (vector? file) + (= (vector-length file) 3) + (eq? (vector-ref file 0) 'secure-static-file))) + (display-path (if secure-token? (vector-ref file 1) file)) + (ct (or (mime-type-for display-path) "application/octet-stream")) (headers (list (cons "Content-Type" ct))) (max-bytes (if (null? maybe-max-bytes) 8388608 (car maybe-max-bytes))) - (body (read-file-limited full-path max-bytes))) + (body (if secure-token? + (let ((contents (vector-ref file 2))) + (when (> (bytevector-length contents) max-bytes) + (error "Static file exceeds configured byte limit" + display-path)) + contents) + ;; Direct path serving remains available for trusted callers; + ;; request-derived app paths always use the descriptor token. + (read-file-limited file max-bytes)))) (http-respond 200 headers body))) (def (read-file-limited full-path max-bytes) new file mode 100755 --- /dev/null +++ b/support/install-verified-jerbuild.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +lock=${JSINATRA_JERBUILD_LOCK:-"$root/support/jerbuild-bootstrap.lock"} +signers="$root/support/jerboa-release-signers" +dest=${1:-"$root/.deps/bin/jerbuild"} + +read_value() { + local key=$1 + sed -n "s/^${key}=//p" "$lock" | tail -n 1 +} + +[[ -f "$lock" ]] || { echo "verified bootstrap blocked: missing lock" >&2; exit 1; } +status=$(read_value status) +if [[ "$status" != ready ]]; then + echo "verified bootstrap blocked: ${status:-lock-status-missing}" >&2 + exit 1 +fi + +base_url=$(read_value base_url) +asset=$(read_value asset) +expected=$(read_value sha256) +manifest=$(read_value manifest) +signature=$(read_value signature) +identity=$(read_value signer_identity) +namespace=$(read_value namespace) + +[[ "$base_url" == https://* ]] || { echo "bootstrap base URL must use HTTPS" >&2; exit 1; } +[[ "$asset" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe bootstrap asset name" >&2; exit 1; } +[[ "$manifest" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe manifest name" >&2; exit 1; } +[[ "$signature" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "unsafe signature name" >&2; exit 1; } +[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { echo "invalid pinned SHA-256" >&2; exit 1; } +grep -Eq '^[[:space:]]*[^#[:space:]][^[:space:]]*[[:space:]]+ssh-(ed25519|rsa)[[:space:]]+' "$signers" || { + echo "verified bootstrap blocked: pinned signer entry missing" >&2 + exit 1 +} +command -v ssh-keygen >/dev/null || { echo "ssh-keygen is required" >&2; exit 1; } + +umask 077 +tmp=$(mktemp -d "${TMPDIR:-/tmp}/jsinatra-bootstrap.XXXXXXXX") +trap 'rm -rf "$tmp"' EXIT +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "$base_url/$manifest" --output "$tmp/$manifest" +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "$base_url/$signature" --output "$tmp/$signature" +ssh-keygen -Y verify -f "$signers" -I "$identity" -n "${namespace:-file}" \ + -s "$tmp/$signature" < "$tmp/$manifest" + +manifest_digest=$(awk -v name="$asset" '$2 == name {print $1}' "$tmp/$manifest") +[[ "$manifest_digest" == "$expected" ]] || { echo "signed manifest digest mismatch" >&2; exit 1; } +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "$base_url/$asset" --output "$tmp/$asset" +actual=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}') +[[ "$actual" == "$expected" ]] || { echo "jerbuild digest mismatch" >&2; exit 1; } +mkdir -p "$(dirname "$dest")" +install -m 0755 "$tmp/$asset" "$dest" +printf 'verified jerbuild sha256=%s\n' "$actual" new file mode 100644 --- /dev/null +++ b/support/jerboa-release-signers @@ -0,0 +1,2 @@ +# OpenSSH allowed-signers entries belong here. No authenticated Jerboa release +# identity has been published, so CI bootstrap remains deliberately blocked. new file mode 100644 --- /dev/null +++ b/support/jerbuild-bootstrap.lock @@ -0,0 +1,11 @@ +# Fail closed until the Jerboa producer publishes a signed manifest and a +# release identity is provisioned below. A versioned same-origin asset alone +# is not an authenticated bootstrap. +status=blocked-awaiting-authenticated-upstream-release +base_url= +asset= +sha256= +manifest= +signature= +signer_identity=jerboa-release +namespace=file --- a/support/soak-evidence.ss +++ b/support/soak-evidence.ss @@ -41,6 +41,13 @@ (define (not-contains? s needle) (not (contains? s needle))) +(define (response-body-text resp) + (let ((body (th:response-body resp))) + (cond + ((string? body) body) + ((bytevector? body) (utf8->string body)) + (else body)))) + (define (vars . pairs) (let ((ht (make-hash-table))) (let loop ((rest pairs)) @@ -229,7 +236,7 @@ (make-request "GET" "/ok.txt" '() "") 200 (lambda (resp) - (string=? (th:response-body resp) "public ok\n")))) + (string=? (response-body-text resp) "public ok\n")))) (define (run-head) (run-request "head" --- a/support/web-security-corpus.ss +++ b/support/web-security-corpus.ss @@ -194,6 +194,12 @@ (and (>= s-len suffix-len) (string=? (substring s (- s-len suffix-len) s-len) suffix)))) +(define (secure-static-token? value expected-suffix) + (and (vector? value) + (= (vector-length value) 3) + (eq? (vector-ref value 0) 'secure-static-file) + (string-suffix? expected-suffix (vector-ref value 1)))) + (define (run-static-case i) (if (< (modulo i 8) 3) (accepted-case @@ -201,11 +207,9 @@ (lambda () (case (modulo i 3) [(0) (let ((path (try-static-file static-app "/ok.txt"))) - (and (string? path) - (string-suffix? "/ok.txt" path)))] + (secure-static-token? path "ok.txt"))] [(1) (let ((path (try-static-file static-app "/nested/ok.txt"))) - (and (string? path) - (string-suffix? "/nested/ok.txt" path)))] + (secure-static-token? path "nested/ok.txt"))] [else (begin (serve-static-file ok-file 1024) #t)]))) (rejected-case (format "static-reject-~a" i) --- a/support/web-security-smoke.ss +++ b/support/web-security-smoke.ss @@ -19,6 +19,11 @@ (define (fixture-path name) (string-append (getenv "JERBOA_SINATRA_STATIC_FIXTURE") "/" name)) +(define (secure-static-token? value) + (and (vector? value) + (= (vector-length value) 3) + (eq? (vector-ref value 0) 'secure-static-file))) + (define web-security-smoke-test (test-suite "web security smoke" @@ -62,13 +67,35 @@ (check (th:response-status smuggle) => 400) (check (th:response-status override) => 400)))) + (test-case "error responses are safe by default and escaped when opted in" + (let ((safe-app (make-sinatra-app))) + (sinatra-get safe-app "/boom" + (lambda () (error "<script>alert(1)</script>" 'boom))) + (let* ((handler (sinatra-handler safe-app)) + (resp (handler + (th:make-request "GET" "/boom" "HTTP/1.1" '() "")))) + (check (th:response-body resp) => "<h1>Internal Server Error</h1>"))) + (let ((debug-app (make-sinatra-app))) + (app-setting-set! debug-app "environment" "development") + (app-setting-set! debug-app "show-exceptions" #t) + (sinatra-get debug-app "/boom" + (lambda () (error "<script>alert(1)</script>" 'boom))) + (let* ((handler (sinatra-handler debug-app)) + (resp (handler + (th:make-request "GET" "/boom" "HTTP/1.1" '() ""))) + (body (th:response-body resp))) + (check (contains? body "<script>") => #f) + (check (contains? body "<script>") => #t)))) + (test-case "static files stay contained and capped" (let ((app (make-sinatra-app)) (public-dir (getenv "JERBOA_SINATRA_STATIC_FIXTURE"))) (app-setting-set! app "public-folder" public-dir) (let ((ok-file (try-static-file app "/ok.txt"))) - (check (string? ok-file) => #t)) + (check (secure-static-token? ok-file) => #t)) (check (try-static-file app "/../secret.txt") => #f) + (check (try-static-file app "/outside-link.txt") => #f) + (check (try-static-file app "/linked-dir/secret.txt") => #f) (check-exception (lambda () (serve-static-file (fixture-path "big.txt") 4)) (lambda (e) #t)))))) @@ -86,3 +113,5 @@ (newline) (display "static_file_status=pass") (newline) +(display "error_escape_status=pass") +(newline)