import LeanTea import LeanTea.Html.Safe import LeanTea.Net.SafePath import LeanTea.Net.SafeRedirect import LeanTea.Net.Csp import LeanTea.Os.SafeCmd import LeanTea.Form.Csrf import LeanTea.StateMachine import StateMachine.Order /-! # examples/Tests/SecuritySpec.lean — one binary for the construction-time security guarantees. Consolidates the per-primitive smoke binaries (SafeHtml, SafePath, SafeCmd, SafeHeader, SafeRedirect) into a single LSpec runner so CI runs **one step** instead of five. The richer integration smokes (`auth_proof_smoke`, `safequery_smoke`) keep their own binaries because they spin up SQLite + sessions. Each `group` mirrors one primitive's `SECURITY.md` section. Adding a new allow-list entry to a primitive? Add an `it` here and the build fails until the new case is green. -/ open LeanTea LeanTea.Html LeanTea.LSpec open LeanTea.Net.Http (Response) open LeanTea.Net (SafePath SafeRedirect Csp CspSrc) open LeanTea.Net.Http (Response.csp) open LeanTea.Os (SafeCmd) open LeanTea.Form.Csrf (Config hiddenInput) /-! ## helpers -/ private def isOk [Inhabited α] : Except String α → Bool | .ok _ => true | .error _ => false private def isError [Inhabited α] : Except String α → Bool | .ok _ => false | .error _ => true private def hasSubstr (haystack needle : String) : Bool := (haystack.splitOn needle).length > 1 /-! ## 1 · SafeHtml — XSS via attribute names and URL schemes -/ def safeHtmlSpec : LSpec := group "SafeHtml (Primitive 4)" [ group "rejected attribute names" [ it "onclick rejected" (isError (SafeAttr.text "onclick" "x")), it "onerror rejected" (isError (SafeAttr.text "onerror" "x")), it "style (not on allow-list)" (isError (SafeAttr.text "style" "x")) ], group "rejected URL schemes" [ it "javascript: rejected" (isError (SafeAttr.url "href" "javascript:alert(1)")), it "data:text/html rejected" (isError (SafeAttr.url "href" "data:text/html,1")), it "case-insensitive javascript:" (isError (SafeAttr.url "href" "JaVaScRiPt:1")), it "vbscript: rejected" (isError (SafeAttr.url "href" "vbscript:msgbox()")) ], group "accepted (allow-list)" [ it "class accepted" (isOk (SafeAttr.text "class" "btn")), it "data-* accepted" (isOk (SafeAttr.text "data-test" "x")), it "aria-* accepted" (isOk (SafeAttr.text "aria-label" "x")), it "relative URL accepted" (isOk (SafeAttr.url "href" "/login")), it "https URL accepted" (isOk (SafeAttr.url "href" "https://x.com")), it "mailto URL accepted" (isOk (SafeAttr.url "href" "mailto:hi@x.com")) ] ] /-! ## 2 · SafePath — Path Traversal -/ def safePathSpec : LSpec := let ws := "/srv/uploads" let okAndEq (s : Except String SafePath) (expected : String) : Bool := match s with | .ok p => p.value == expected | .error _ => false group "SafePath (Primitive 5)" [ group "accepted" [ it "relative joined" (okAndEq (SafePath.under ws "a.txt") "/srv/uploads/a.txt"), it "nested relative" (okAndEq (SafePath.under ws "sub/dir/b.txt") "/srv/uploads/sub/dir/b.txt"), it "absolute under ws" (okAndEq (SafePath.under ws "/srv/uploads/sub/x") "/srv/uploads/sub/x"), it "`.`/`..` normalised" (okAndEq (SafePath.under ws "a/./b/../c.txt") "/srv/uploads/a/c.txt"), it "trailing-slash workspace" (okAndEq (SafePath.under "/srv/uploads/" "ok.txt") "/srv/uploads/ok.txt") ], group "rejected" [ it "`..` escapes" (isError (SafePath.under ws "../etc/passwd")), it "deep `..` escapes" (isError (SafePath.under ws "a/../../etc/passwd")), it "absolute outside ws" (isError (SafePath.under ws "/etc/passwd")), it "sibling-prefix attack" (isError (SafePath.under ws "/srv/uploads-attacker/x")), it "NUL byte" (isError (SafePath.under ws "a.txt\u0000.png")) ] ] /-! ## 3 · SafeCmd — OS Command Injection -/ def safeCmdSpec : LSpec := group "SafeCmd (Primitive 6)" [ group "accepted argv-style" [ it "echo" (isOk (SafeCmd.exec "echo" ["hi"])), it "/usr/bin/env" (isOk (SafeCmd.exec "/usr/bin/env" ["printf", "x"])), it "ls" (isOk (SafeCmd.exec "ls" ["-la"])) ], group "rejected shells by basename" [ it "sh" (isError (SafeCmd.exec "sh" ["-c", "evil"])), it "bash" (isError (SafeCmd.exec "bash" ["-c", "evil"])), it "/usr/bin/bash" (isError (SafeCmd.exec "/usr/bin/bash" ["-c", "evil"])), it "/bin/zsh" (isError (SafeCmd.exec "/bin/zsh" ["-c", "evil"])) ], group "rejected NUL bytes" [ it "NUL in cmd" (isError (SafeCmd.exec "echo\u0000sh" ["hi"])), it "NUL in args" (isError (SafeCmd.exec "echo" ["hi\u0000; rm"])) ] ] /-! ## 4 · SafeHeader — HTTP header injection + clickjacking baseline -/ def safeHeaderSpec : LSpec := let r0 : Response := .text 200 "ok" let serialised (r : Response) : String := String.fromUTF8! r.toBytes let defaultHas (needle : String) : Bool := hasSubstr (serialised r0.defaultSecurityHeaders) needle let optedOut := r0.defaultSecurityHeaders (frameOptions := none) let optedOutS := serialised optedOut let traceBytes := match r0.setHeader "x-trace-id" "abc-123" with | .ok r => serialised r | .error _ => "" let redirSerialised := serialised (Response.redirect "/dash\r\nset-cookie: pwned=1") group "Response.setHeader + defaultSecurityHeaders (Primitive 7)" [ group "CRLF/NUL rejected" [ it "CRLF in name" (isError (r0.setHeader "x-evil\r\nset-cookie" "x")), it "CRLF in value" (isError (r0.setHeader "x-test" "evil\r\nset-cookie: a=b")), it "LF-only in value" (isError (r0.setHeader "x-test" "evil\nset-cookie: a=b")), it "NUL in value" (isError (r0.setHeader "x-test" "ok\u0000x")) ], group "safe header lives through serialisation" [ it "x-trace-id appears in toBytes" (hasSubstr traceBytes "x-trace-id: abc-123") ], group "defaultSecurityHeaders" [ it "X-Frame-Options: DENY" (defaultHas "x-frame-options: DENY"), it "X-Content-Type-Options: nosniff" (defaultHas "x-content-type-options: nosniff"), it "Referrer-Policy: no-referrer" (defaultHas "referrer-policy: no-referrer"), it "Permissions-Policy: …" (defaultHas "permissions-policy: geolocation=()") ], group "frameOptions := none opts out" [ it "X-Frame-Options absent" (!hasSubstr optedOutS "x-frame-options"), it "but nosniff still present" (hasSubstr optedOutS "x-content-type-options: nosniff") ], group "Response.redirect strips CR/LF (defence in depth)" [ it "no injected `\\r\\nset-cookie:`" (!hasSubstr redirSerialised "\r\nset-cookie: pwned"), it "location header still emitted" (hasSubstr redirSerialised "location: /dash") ] ] /-! ## 5 · SafeRedirect — Open Redirect -/ def safeRedirectSpec : LSpec := let trusted := ["https://app.example", "https://api.example.com"] let okAndEq (s : Except String SafeRedirect) (expected : String) : Bool := match s with | .ok r => r.location == expected | .error _ => false group "SafeRedirect (Primitive 8)" [ group "accepted relative paths" [ it "/dashboard" (okAndEq (SafeRedirect.to trusted "/dashboard") "/dashboard"), it "path+query" (okAndEq (SafeRedirect.to trusted "/users/me?x=1") "/users/me?x=1") ], group "accepted trusted origins" [ it "exact origin" (okAndEq (SafeRedirect.to trusted "https://app.example") "https://app.example"), it "path under origin" (isOk (SafeRedirect.to trusted "https://app.example/foo/bar")) ], group "rejected protocol-relative" [ it "//evil.example" (isError (SafeRedirect.to trusted "//evil.example/x")), it "/\\\\evil.example" (isError (SafeRedirect.to trusted "/\\evil.example/x")) ], group "rejected dangerous schemes" [ it "javascript:" (isError (SafeRedirect.to trusted "javascript:alert(1)")), it "javascript: case-insens" (isError (SafeRedirect.to trusted "JaVaScRiPt:1")), it "data:" (isError (SafeRedirect.to trusted "data:text/html,1")), it "vbscript:" (isError (SafeRedirect.to trusted "vbscript:1")), it "file:" (isError (SafeRedirect.to trusted "file:///etc/passwd")) ], group "rejected: not on allow-list" [ it "evil origin" (isError (SafeRedirect.to trusted "https://evil.example/x")), it "sibling-prefix origin" (isError (SafeRedirect.to trusted "https://app.example.evil.com/x")) ], group "rejected: CR/LF/NUL (defence in depth)" [ it "CRLF" (isError (SafeRedirect.to trusted "/foo\r\nset-cookie: pwned")), it "NUL" (isError (SafeRedirect.to trusted "/foo\u0000bar")) ] ] /-! ## 6 · CSP typed builder -/ def cspSpec : LSpec := let strict := Csp.strict.render let custom : Csp := { Csp.strict with scriptSrc := [.self, .host "https://cdn.example.com", .nonce "abc123"], reportTo := some "csp-endpoint" } let customRendered := custom.render let reportOnly : Csp := { Csp.strict with reportOnly := true } group "Net.Csp (Primitive 9)" [ group "render emits the directive name and source keywords" [ it "strict has default-src 'none'" (hasSubstr strict "default-src 'none'"), it "strict has script-src 'self'" (hasSubstr strict "script-src 'self'"), it "strict has frame-ancestors 'none'" (hasSubstr strict "frame-ancestors 'none'"), it "directive separator is `; `" (hasSubstr strict "; ") ], group "custom directives are typed values, not strings" [ it "host source rendered verbatim" (hasSubstr customRendered "https://cdn.example.com"), it "nonce wrapped in single quotes" (hasSubstr customRendered "'nonce-abc123'"), it "report-to directive present" (hasSubstr customRendered "report-to csp-endpoint") ], group "reportOnly flips the header *name*" [ it "Csp.strict → enforce header" (Csp.strict.headerName == "content-security-policy"), it "reportOnly → report-only header" (reportOnly.headerName == "content-security-policy-report-only") ], group "Response.csp attaches via setHeader! (CR/LF still refused)" [ it "header lands in toBytes" ( let resp : Response := (Response.text 200 "ok").csp Csp.strict let s := String.fromUTF8! resp.toBytes hasSubstr s "content-security-policy: default-src 'none'") ] ] /-! ## 7 · CSRF double-submit token -/ def csrfSpec : LSpec := let cfg : LeanTea.Form.Csrf.Config := { secure := false } let mkReq (cookie body : String) : LeanTea.Net.Http.Request := { method := "POST", path := "/save", query := "", headers := if cookie.isEmpty then #[] else #[("cookie", cookie)], body := body.toUTF8 } let goodReq := mkReq "csrf=abc123" "_csrf=abc123&other=field" let mismatchReq := mkReq "csrf=abc123" "_csrf=DIFFERENT&other=field" let noCookieReq := mkReq "" "_csrf=abc123" let noFieldReq := mkReq "csrf=abc123" "other=field" let html := (LeanTea.Form.Csrf.hiddenInput cfg "abc123").render let okOf (e : Except String Unit) : Bool := match e with | .ok () => true | .error _ => false let errOf (e : Except String Unit) : Bool := match e with | .ok () => false | .error _ => true group "Form.Csrf (Primitive 10)" [ group "verify accepts matching cookie + field" [ it "double-submit matches" (okOf (cfg.verify goodReq)) ], group "verify refuses tampering" [ it "mismatched values" (errOf (cfg.verify mismatchReq)), it "missing cookie" (errOf (cfg.verify noCookieReq)), it "missing field" (errOf (cfg.verify noFieldReq)) ], group "hidden input renders as expected" [ it "name attribute" (hasSubstr html "name=\"_csrf\""), it "value attribute" (hasSubstr html "value=\"abc123\""), it "type=hidden" (hasSubstr html "type=\"hidden\"") ] ] /-! ## 8 · State-machine GADT (Order example) -/ def stateMachineSpec : LSpec := /- Build the legal lifecycle. Each `apply` is a separate compile step — the types stitch through `draft → submitted → paid → shipped` exactly because the constructors line up. -/ let order0 : OrderSM.Order .draft := OrderSM.fresh 1 1000 let order1 : OrderSM.Order .submitted := order0.apply .submit let order2 : OrderSM.Order .paid := order1.apply .pay let order3 : OrderSM.Order .shipped := order2.apply .ship let order2c : OrderSM.Order .cancelled := order2.apply .cancel group "StateMachine (Primitive 11) — Order lifecycle" [ group "legal transitions compile and update the payload" [ it "draft.submit preserves total" (order1.id == 1 && order1.total == 1000), it "submitted.pay fills paidAmt" (order2.paidAmt == 1000), it "paid.ship sets shippedAt" (order3.shippedAt == 1_700_000_000), it "any.cancel zeroes payload" (order2c.total == 0 && order2c.paidAmt == 0) ], group "illegal transitions are documented in source as compile errors" [ /- Try uncommenting: let bad := order0.apply OrderSM.Transition.pay -- error: application type mismatch -- expected OrderTransition .draft ? -- got OrderTransition .submitted .paid and the build refuses to proceed. There is **no value-level path** from a `.draft` order to `.paid` — Lean's dependent types make the bug class unrepresentable. -/ it "(see source comment block — the compile error is the test)" true ] ] /-! ## Aggregate tree + entry point -/ def allSpecs : LSpec := group "LeanTEA construction-time security" [ safeHtmlSpec, safePathSpec, safeCmdSpec, safeHeaderSpec, safeRedirectSpec, cspSpec, csrfSpec, stateMachineSpec ] def main : IO Unit := do let code ← lspecIO allSpecs if code != 0 then IO.Process.exit code.toUInt8