1 # ezhttp/src/cors: Fetch CORS response headers for a simple request and an OPTIONS 2 # preflight. Origins are reflected, or `*`, and credentials never pair with `*`. 3 # See https://fetch.spec.whatwg.org/#cors-protocol. 4 import Base 5 import ./http.bend as Http 6 7 # allow-list for a server. `origins` of `["*"]` allows any origin. 8 type Cfg is Data: 9 Cfg{ 10 origins: List<&2, String> 11 methods: List<&2, String> 12 headers: List<&2, String> 13 credentials: Bool 14 max_age: String 15 expose: List<&2, String> 16 } 17 18 # whether `*` is one of the allowed origins 19 def cors.star.step(hit: Bool, rest: Unit -> Bool) -> Bool: 20 match hit: 21 case True{}: 22 True{} 23 case False{}: 24 rest(Unit{}) 25 26 # walk origins for `*` 27 def cors.star(os: List<&2, String>) -> Bool: 28 match os: 29 case []: 30 False{} 31 case h <> t: 32 cors.star.step(String.eq(h, "*"), _u => cors.star(t)) 33 34 # case-sensitive membership 35 def cors.hit.step(hit: Bool, rest: Unit -> Bool) -> Bool: 36 match hit: 37 case True{}: 38 True{} 39 case False{}: 40 rest(Unit{}) 41 42 # whether the needle is in the list 43 def cors.hit(+needle: String, xs: List<&2, String>) -> Bool: 44 match xs: 45 case []: 46 False{} 47 case h <> t: 48 cors.hit.step(String.eq(h, needle), _u => cors.hit(needle, t)) 49 50 # lowercase a list of names 51 def cors.lower(xs: List<&2, String>) -> List<&2, String>: 52 match xs: 53 case []: 54 [] 55 case h <> t: 56 String.to_lower(h) <> cors.lower(t) 57 58 # case-insensitive membership (header names, RFC 9110 ยง5.1) 59 def cors.hit_ci(+needle: String, xs: List<&2, String>) -> Bool: 60 cors.hit(String.to_lower(needle), cors.lower(xs)) 61 62 # `*`, or an explicit origin 63 def cors.allowed(+os: List<&2, String>, origin: String) -> Bool: 64 Bool.or(cors.star(os), cors.hit(origin, os)) 65 66 # `*` when every origin is allowed and credentials are off; else the origin 67 def cors.acao.star(origin: String, star: Bool) -> String: 68 match star: 69 case True{}: 70 "*" 71 case False{}: 72 origin 73 74 # credentials always reflect the origin (Fetch: `*` cannot be used) 75 def cors.acao.cred(+os: List<&2, String>, origin: String, credentials: Bool) 76 -> String: 77 match credentials: 78 case True{}: 79 origin 80 case False{}: 81 cors.acao.star(origin, cors.star(os)) 82 83 # empty when the origin is not allowed 84 def cors.acao.ok(+os: List<&2, String>, credentials: Bool, origin: String, 85 ok: Bool) -> String: 86 match ok: 87 case False{}: 88 "" 89 case True{}: 90 cors.acao.cred(os, origin, credentials) 91 92 # Access-Control-Allow-Origin value, or "" when the origin is refused 93 def cors.acao(+os: List<&2, String>, credentials: Bool, +origin: String) -> String: 94 cors.acao.ok(os, credentials, origin, cors.allowed(os, origin)) 95 96 # true when ACAO is a specific origin and Vary: Origin should be set 97 def cors.vary(+acao: String) -> Bool: 98 Bool.and(Bool.not(String.eq(acao, "*")), Bool.not(String.eq(acao, ""))) 99 100 # every requested header name is allowed. Empty means none were requested. 101 def cors.names.step(ok: Bool, rest: Unit -> Bool) -> Bool: 102 match ok: 103 case True{}: 104 rest(Unit{}) 105 case False{}: 106 False{} 107 108 # requested header names against the allow list 109 def cors.names(ps: List<&2, String>, +allow: List<&2, String>) -> Bool: 110 match ps: 111 case []: 112 True{} 113 case h <> t: 114 cors.names.step(cors.hit_ci(String.trim(h), allow), _u => cors.names(t, allow)) 115 116 # an empty request-header list asks for nothing 117 def cors.headers_empty(+requested: String, +allow: List<&2, String>, empty: Bool) 118 -> Bool: 119 match empty: 120 case True{}: 121 True{} 122 case False{}: 123 cors.names(String.split(requested, ','), allow) 124 125 # Access-Control-Request-Headers, comma-separated 126 def cors.headers_ok(+requested: String, +allow: List<&2, String>) -> Bool: 127 cors.headers_empty(requested, allow, String.eq(requested, "")) 128 129 # join with ", " 130 def cors.join(xs: List<&2, String>) -> String: 131 String.join(xs, ", ") 132 133 # append Vary when the origin is reflected 134 def cors.vary_on(hs: List<&2, Http.Header>, _acao: String, vary: Bool) 135 -> List<&2, Http.Header>: 136 match vary: 137 case False{}: 138 hs 139 case True{}: 140 Http.H{"Vary", "Origin"} <> hs 141 142 # append credentials when they are allowed 143 def cors.cred_on(hs: List<&2, Http.Header>, credentials: Bool) 144 -> List<&2, Http.Header>: 145 match credentials: 146 case False{}: 147 hs 148 case True{}: 149 Http.H{"Access-Control-Allow-Credentials", "true"} <> hs 150 151 # preflight response headers 152 def cors.preflight_hs(+acao: String, methods: List<&2, String>, 153 headers: List<&2, String>, credentials: Bool, max_age: String) 154 -> List<&2, Http.Header>: 155 cors.cred_on(cors.vary_on([ 156 Http.H{"Access-Control-Allow-Origin", acao}, 157 Http.H{"Access-Control-Allow-Methods", cors.join(methods)}, 158 Http.H{"Access-Control-Allow-Headers", cors.join(headers)}, 159 Http.H{"Access-Control-Max-Age", max_age} 160 ], acao, cors.vary(acao)), credentials) 161 162 # 400 when a requested header is not allowed; otherwise 200 163 def cors.preflight.headers(acao: String, methods: List<&2, String>, 164 headers: List<&2, String>, credentials: Bool, max_age: String, 165 _req: String, ok: Bool) -> Http.Reply: 166 match ok: 167 case False{}: 168 Http.Reply{400, [], ""} 169 case True{}: 170 Http.Reply{200, cors.preflight_hs(acao, methods, headers, credentials, 171 max_age), ""} 172 173 # 400 when the method is not allowed 174 def cors.preflight.method(acao: String, methods: List<&2, String>, 175 +headers: List<&2, String>, credentials: Bool, max_age: String, 176 +req_headers: String, _method: String, ok: Bool) -> Http.Reply: 177 match ok: 178 case False{}: 179 Http.Reply{400, [], ""} 180 case True{}: 181 cors.preflight.headers(acao, methods, headers, credentials, max_age, 182 req_headers, cors.headers_ok(req_headers, headers)) 183 184 # skip the method check when the origin was refused 185 def cors.preflight.denied(acao: String, +methods: List<&2, String>, 186 headers: List<&2, String>, credentials: Bool, max_age: String, 187 req_headers: String, +method: String, denied: Bool) -> Http.Reply: 188 match denied: 189 case True{}: 190 Http.Reply{400, [], ""} 191 case False{}: 192 cors.preflight.method(acao, methods, headers, credentials, max_age, 193 req_headers, method, cors.hit(method, methods)) 194 195 # OPTIONS preflight (Fetch CORS). Refused origins and methods are 400. 196 def cors.preflight(cfg: Cfg, origin: String, method: String, req_headers: String) 197 -> Http.Reply: 198 match cfg: 199 case Cfg{+origins, methods, headers, +credentials, max_age, expose}: 200 +acao = cors.acao(origins, credentials, origin) 201 cors.preflight.denied(acao, methods, headers, credentials, max_age, 202 req_headers, method, String.eq(acao, "")) 203 204 # Access-Control-Expose-Headers when the list is not empty 205 def cors.expose_of(hs: List<&2, Http.Header>, expose: List<&2, String>, 206 empty: Bool) -> List<&2, Http.Header>: 207 match empty: 208 case True{}: 209 hs 210 case False{}: 211 Http.H{"Access-Control-Expose-Headers", cors.join(expose)} <> hs 212 213 # Access-Control-Expose-Headers when the list is not empty 214 def cors.expose_on(hs: List<&2, Http.Header>, +expose: List<&2, String>) 215 -> List<&2, Http.Header>: 216 cors.expose_of(hs, expose, List.is_empty(&2, String, expose)) 217 218 # simple-response CORS headers (ACAO, optional credentials, expose, vary) 219 def cors.simple_hs(+acao: String, credentials: Bool, expose: List<&2, String>) 220 -> List<&2, Http.Header>: 221 cors.cred_on(cors.vary_on(cors.expose_on([ 222 Http.H{"Access-Control-Allow-Origin", acao} 223 ], expose), acao, cors.vary(acao)), credentials) 224 225 # leave a reply alone when there is nothing to add 226 def cors.simple.skip(add: Bool, reply: Http.Reply, hs: List<&2, Http.Header>) 227 -> Http.Reply: 228 match add reply: 229 case False{} r: 230 r 231 case True{} Http.Torn{why}: 232 Http.Torn{why} 233 case True{} Http.Reply{status, headers, body}: 234 Http.Reply{status, List.append(&2, Http.Header, hs, headers), body} 235 236 # append CORS headers when the origin is allowed 237 def cors.simple.add(reply: Http.Reply, acao: String, credentials: Bool, 238 expose: List<&2, String>, add: Bool) -> Http.Reply: 239 cors.simple.skip(add, reply, cors.simple_hs(acao, credentials, expose)) 240 241 # a non-preflight response. A missing or refused origin is unchanged. 242 def cors.simple(cfg: Cfg, origin: String, reply: Http.Reply) -> Http.Reply: 243 match cfg: 244 case Cfg{+origins, methods, headers, +credentials, max_age, expose}: 245 +acao = cors.acao(origins, credentials, origin) 246 cors.simple.add(reply, acao, credentials, expose, 247 Bool.not(String.eq(acao, ""))) 248 249 # Origin request header 250 def cors.origin(origin: String) -> Http.Header: 251 Http.H{"Origin", origin} 252 253 # Access-Control-Request-Method 254 def cors.request_method(method: String) -> Http.Header: 255 Http.H{"Access-Control-Request-Method", method} 256 257 # Access-Control-Request-Headers 258 def cors.request_headers(names: String) -> Http.Header: 259 Http.H{"Access-Control-Request-Headers", names} 260 261 # OPTIONS with Access-Control-Request-Method is a preflight 262 def cors.is_preflight.method(method: String, asked: String) -> Bool: 263 Bool.and(String.eq(method, "OPTIONS"), Bool.not(String.eq(asked, ""))) 264 265 # whether this request is a CORS preflight 266 def cors.is_preflight(req: Http.Request) -> Bool: 267 match req: 268 case Http.Bad{why}: 269 False{} 270 case Http.Request{method, target, headers, body}: 271 cors.is_preflight.method(method, 272 Http.headers.find(headers, "access-control-request-method")) 273 274 # preflight from the request's CORS headers 275 def cors.preflight_req(cfg: Cfg, req: Http.Request) -> Http.Reply: 276 match req: 277 case Http.Bad{why}: 278 Http.Reply{400, [], why} 279 case Http.Request{method, target, +headers, body}: 280 cors.preflight(cfg, Http.headers.find(headers, "origin"), 281 Http.headers.find(headers, "access-control-request-method"), 282 Http.headers.find(headers, "access-control-request-headers")) 283 284 # simple CORS from the request Origin 285 def cors.simple_req(cfg: Cfg, req: Http.Request, reply: Http.Reply) -> Http.Reply: 286 match req: 287 case Http.Bad{why}: 288 reply 289 case Http.Request{method, target, headers, body}: 290 cors.simple(cfg, Http.headers.find(headers, "origin"), reply) 291 292 # preflight, or CORS headers on the handler reply 293 def cors.route(cfg: Cfg, req: Http.Request, +reply: Http.Reply, pre: Bool) 294 -> Http.Reply: 295 match pre: 296 case True{}: 297 cors.preflight_req(cfg, req) 298 case False{}: 299 cors.simple_req(cfg, req, reply) 300 301 # answer a request: preflight or simple CORS on top of `reply` 302 def cors.on(cfg: Cfg, +req: Http.Request, reply: Http.Reply) -> Http.Reply: 303 cors.route(cfg, req, reply, cors.is_preflight(req))