src/cors.bend

emerging-ezhttp@0.20.0.0 · 303 lines · 0 definitions

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.
4import Base
5import emerging-ezhttp@0.20.0.0/src/http.bend as Http
6
7# allow-list for a server. `origins` of `["*"]` allows any origin.
8type 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
19def 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 `*`
27def 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
35def 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
43def 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
51def 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)
59def 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
63def 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
67def 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)
75def 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
84def 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
93def 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
97def 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.
101def 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
109def 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
117def 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
126def cors.headers_ok(+requested: String, +allow: List<&2, String>) -> Bool:
127  cors.headers_empty(requested, allow, String.eq(requested, ""))
128
129# join with ", "
130def cors.join(xs: List<&2, String>) -> String:
131  String.join(xs, ", ")
132
133# append Vary when the origin is reflected
134def 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
143def 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
152def 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
163def 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
174def 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
185def 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.
196def 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
205def 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
214def 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)
219def 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
226def 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
237def 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.
242def 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
250def cors.origin(origin: String) -> Http.Header:
251  Http.H{"Origin", origin}
252
253# Access-Control-Request-Method
254def cors.request_method(method: String) -> Http.Header:
255  Http.H{"Access-Control-Request-Method", method}
256
257# Access-Control-Request-Headers
258def 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
262def 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
266def 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
275def 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
285def 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
293def 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`
302def cors.on(cfg: Cfg, +req: Http.Request, reply: Http.Reply) -> Http.Reply:
303  cors.route(cfg, req, reply, cors.is_preflight(req))