Bendo ships four built-ins for browser-facing apps. They follow Hono's cors,
secureHeaders, methodOverride and trimTrailingSlash/appendTrailingSlash
middleware, with the differences listed in each section. Like the
observability and authentication built-ins,
you wrap them in your app's middleware tag and delegate to B.builtin.
The complete compiled example is
tests/servers/mw-cors.bend:
B.on_request(Tag, Builtin{B.method_override()}),
B.use(Tag, Builtin{B.trim_trailing_slash()}),
B.use_path(Tag, "/api/*", Builtin{B.cors(B.cors_allow([
B.cors_origin("https://app.example"),
B.cors_origin_credentials("https://admin.example")
]))}),
B.use(Tag, Builtin{B.secure_headers()}),Method override must be an on_request hook. It is the only stage that runs
before routing, so only there does the new method select the route. Registered
with B.use, the handler would see the new method but the route would already
have been chosen by the old one.
CORS, secure headers and trailing slash are ordinary middleware. Register them
with B.use or B.use_path, before the middleware whose responses they should
cover, since the first registration is the outermost layer. They then cover the
handler, inner middleware, inner short circuits and the router's 404 and 405
answers.
CORS and security middleware retain the policy that applies to this request.
After after_handle, map_response, on_error and final response validation,
the framework applies those policies again before observers and transport.
Replacement responses and validation-generated 500s carry the same CORS grant
and security headers. An invalid custom field produces a 500; valid policy
fields, request ID and timing still reach that response. Existing Vary fields
are merged, including Vary: *.
Requests rejected before middleware runs (malformed HTTP or bad query encoding)
are outside these policies. A middleware registered inside a short circuit
cannot cover that response, because it never ran.
B.cors(config) answers preflights and adds CORS fields to other responses.
Build config with one of:
B.cors_defaults(): every origin getsAccess-Control-Allow-Origin: *. Credentials are never allowed: browsers reject*with credentials, so the type has no way to ask for both.B.cors_allow(origins): only listed origins get a grant. Build each entry withB.cors_origin(origin)or, to also sendAccess-Control-Allow-Credentials: trueto that origin,B.cors_origin_credentials(origin). Credentials are chosen per origin.
For credentialed origins, list header names explicitly. A configured
headers: ["*"] is sent literally; browsers do not treat it as a wildcard on
credentialed requests. Leaving headers unconfigured reflects the requested names.
Adjust either with B.cors_methods(config, methods),
B.cors_headers(config, headers), B.cors_expose(config, headers) and
B.cors_max_age(config, seconds).
| Setting | Default | Sent as |
|---|---|---|
| methods | GET,HEAD,PUT,POST,DELETE,PATCH,QUERY (Hono 4's list) |
Access-Control-Allow-Methods on preflights |
| headers | none: the preflight's requested headers are reflected | Access-Control-Allow-Headers on preflights |
| expose | none | Access-Control-Expose-Headers on other responses |
| max age | none | Access-Control-Max-Age on preflights |
A request's origin is its Origin field serialized as an origin, with the
parser the CSRF middleware uses (src/http/origin.bend):
scheme and host in lowercase, the default port dropped. So
https://APP.example:443 matches a configured https://app.example, and the
grant echoes the serialized form. http://app.example,
https://app.example:8443 and https://sub.app.example are other origins.
Hosts must be written the way browsers send them, as described in
the CSRF section: an entry such as http://127.1 or
https://[0::1] is dropped, so configure http://127.0.0.1 or
https://[::1].
Dropped entries are silent with B.cors_allow. Build the list once at
startup with B.cors_allow_checked(origins) instead, an IO(B.CorsConfig),
and each dropped entry prints one line on stderr:
bendo: cors: ignoring allowed origin "app.example": not an http or https URL
Configured entries may be full URLs; only their origin counts, so
https://app.example/login configures https://app.example. Request Origin
values must be bare origins: a path, userinfo or another scheme is not an
origin and is denied.
Origin: null comes from sandboxed iframes, file: pages, some redirects and
privacy-sensitive contexts. Any of those pages could send it, so it cannot
identify anyone. A listed policy never matches it, and configuring "null" (or
"*") in B.cors_allow has no effect. B.cors_defaults() answers null
origins with *, like every other origin, still without credentials.
With B.cors_allow, a request with two Origin fields gets no grant, even if
one copy is listed. B.cors_defaults() still answers it with *, since *
does not depend on the origin and never carries credentials.
A preflight is an OPTIONS request with an Origin field and an
Access-Control-Request-Method field. The middleware answers it with an empty
204 and does not call the handler. An allowed origin gets, in order:
Access-Control-Allow-Origin, Access-Control-Allow-Credentials when that
origin has credentials, the methods, the headers and the max age. A denied
origin gets the 204 with no CORS field, so the browser blocks the request.
Hono sends the methods and headers to denied origins too.
Any other request, including an OPTIONS request without those fields, goes
to the app. The middleware then removes any Access-Control-Allow-Origin,
Access-Control-Allow-Credentials, Access-Control-Allow-Methods,
Access-Control-Allow-Headers, Access-Control-Max-Age and
Access-Control-Expose-Headers fields
the app set, and adds its own for an allowed origin. Two conflicting grants
are never sent, and a handler cannot grant an origin the policy denies.
When no headers are configured, the requested headers are reflected: every
Access-Control-Request-Headers field is combined, split on commas, trimmed,
lowercased, and items that are not HTTP tokens are dropped.
With B.cors_allow, every response varies by origin, whether the origin was
allowed, denied or absent. A cache therefore never replays one origin's
answer to another. Preflights that reflect requested headers also vary by
Access-Control-Request-Headers. B.cors_defaults() answers every origin the
same way and adds no Vary: Origin.
Every existing Vary field is merged into one: the app's values stay as
written, and each added name is appended unless the list already has it,
compared without case. Vary: * stays *.
B.secure_headers() sets Hono's default security headers after next, replacing
any value the app set, and removes X-Powered-By:
| Header | Value | Why |
|---|---|---|
Cross-Origin-Resource-Policy |
same-origin |
Other sites cannot embed your responses with <img> or <script>, which blocks Spectre-style reads of them. |
Cross-Origin-Opener-Policy |
same-origin |
A cross-origin page your page opens, or that opens it, gets no window handle to it. |
Origin-Agent-Cluster |
?1 |
Asks the browser to isolate the origin in its own agent cluster, so same-site pages cannot share its memory. |
Referrer-Policy |
no-referrer |
Links leave no URL, so paths and query strings with tokens do not leak to other sites. |
Strict-Transport-Security |
max-age=15552000; includeSubDomains |
After one HTTPS visit, the browser uses HTTPS for 180 days, for subdomains too, which defeats SSL-stripping. |
X-Content-Type-Options |
nosniff |
The browser trusts Content-Type instead of guessing, so an upload cannot run as a script or style sheet. |
X-DNS-Prefetch-Control |
off |
The browser does not resolve link hosts in advance, which would tell DNS servers what a page links to. |
X-Download-Options |
noopen |
Old Internet Explorer saves downloads instead of opening them in the site's context. |
X-Frame-Options |
SAMEORIGIN |
Only your own pages can frame yours, which blocks clickjacking. |
X-Permitted-Cross-Domain-Policies |
none |
Flash and PDF readers ignore any crossdomain.xml, so they cannot load your data cross-origin. |
X-XSS-Protection |
0 |
Turns off the old XSS auditors, whose filtering could itself be abused to leak or break pages. Modern browsers have removed them. |
Cross-Origin-Embedder-Policy is off by default, as in Hono: require-corp
breaks pages that load cross-origin images or scripts without CORP headers.
Browsers ignore Strict-Transport-Security over plain HTTP, so it is harmless
in local development.
To change the set, start from B.secure_defaults() and pass the result to
B.secure_headers_with(config):
B.secure_set(config, name, value)adds a header or replaces a default, for exampleContent-Security-Policy.B.secure_unset(config, name)stops sending one. The app's own value for that header then passes through.B.secure_keep_powered_by(config)keepsX-Powered-By.
Names ignore case. An invalid name or value fails normal response validation as 500.
Content-Security-Policy is not sent by default, as in Hono. A useful policy
lists the exact script, style, image and connect sources one app needs, and a
generic one either breaks pages or allows too much. Add yours with
B.secure_set(config, "Content-Security-Policy", "default-src 'self'").
Nonces are not generated.
HTML forms can only send GET and POST. Method override lets a form ask for
PUT, PATCH or DELETE:
<form method="post" action="/todos/42">
<input type="hidden" name="_method" value="DELETE">
</form>B.method_override()reads the_methodfield and allowsPUT,PATCHandDELETE.B.method_override_form(field, methods)reads another field.B.method_override_header(name, methods)reads a request header, such asX-HTTP-Method-Override.
The form field is read from application/x-www-form-urlencoded and
multipart/form-data bodies; other content types are left alone. Only a
POST is overridden, like Rails and Laravel; Hono also rewrites other
non-GET methods. The value is trimmed and compared without case.
| Override values on a POST | Result |
|---|---|
| none, or one empty value | stays POST |
one value naming POST |
stays POST |
| one value naming a listed method | the method is replaced, then routing runs |
| one value naming any other method | 400 Bad Request |
| two or more values (fields or headers) | 400 Bad Request |
| an unreadable form body, or a file part with the field's name | 400 Bad Request |
The body, query and headers stay unchanged, including the _method field.
Hono removes the field.
GET, HEAD, OPTIONS, CONNECT and TRACE are never produced, even when
listed, and no extension method spelled like one can stand in for them. This
keeps CSRF protection sound in either registration order: a
cross-site form POST that becomes PUT is still an unsafe form request, so
the CSRF check inspects it. Had it become GET, the CSRF check would skip it.
B.trim_trailing_slash()redirects/about/to/aboutwhen the route answers 404.B.append_trailing_slash()redirects/aboutto/about/when the route answers 404.B.trim_trailing_slash_always()andB.append_trailing_slash_always()redirect before callingnext, whether or not a route exists. Registered withB.use, they run after routing but before the handler. To redirect before routing and before any other ordinary middleware, register an_alwaysform withB.on_requestinstead. The 404 forms need the route's answer, so keep them inB.use.
Only GET and HEAD are redirected, with 301, as in Hono. The Location is
the new path plus the raw query, so /about/?a=1&b=%20x moves to
/about?a=1&b=%20x. / is never trimmed, and OPTIONS * is never touched.
A redirect is sent only when its target is a same-origin path: it starts with
exactly one / and contains no control character, space, DEL or \. Without
that check, trimming //evil.example/ would answer
Location: //evil.example, which a browser follows to another host. Browsers
also turn \ into / and drop tabs and newlines, so /\evil.example/ is
refused too. Such requests keep their original answer, usually 404.
LAWS.bend proves the pure decisions with the strict kernel. The shells that
read fields and edit responses are covered by the integration tests.
CORS (src/mw/cors_policy.bend against proof/cors-spec.bend):
cors_grant_spec: the grant equalsCorsSpec.grantfor every policy andOriginfield.cors_wildcard_never_credentials:B.cors_defaults()grants*without credentials for every request.cors_listed_denies_unusable_origin: a listed policy grants nothing when the request has no usable origin: none, repeated,nullor malformed. It never falls back to*.cors_listed_grant_echoes_origin: whatever a listed policy grants names exactly the request's own serialized origin.cors_configured_origins: configured entries compare as their URL origins, and entries that are not http or https URLs are dropped.cors_preflight_spec: a preflight is exactlyOPTIONSwithOriginandAccess-Control-Request-Method.cors_preflight_fields_specandcors_actual_fields_spec: the CORS fields equal the specification, with the configured values in order, and none for a denied origin.
Response finalization (src/mw/response_policy.bend and request policies
against the response specs in proof/):
cors_response_spec: for every configuration, request and downstream response, derive the grant from the request, remove all six downstream CORS grant fields and emit the complete actual or preflight field set.cors_vary_origin: listed-origin policies preserve existingVaryvalues and addOriginwhen absent; an existing*stays*.vary_add_spec: merging matches the independent Vary specification for every existing field list.secure_headers_defaults_spec: applying defaults to every response removes conflicting fields andX-Powered-By, adds the eleven literal values inproof/secure-headers-spec.bend, and preserves unrelated response data.
Method override (src/mw/method_override_policy.bend against
proof/override-spec.bend):
override_decision_spec: the decision equalsOverrideSpec.decide, which ties a rewrite to exactly the submitted, normalized method.override_rewrites_only_post,override_never_safe_destination,override_never_safe_nameandoverride_destination_listed: a rewrite comes from a POST, is notGETorHEAD, is not named like a safe method, and is listed.override_rejects_repeatedandoverride_keeps_non_post.override_allowlist_unsafe: a configured allowlist never containsGETorHEAD.
Trailing slash (src/mw/trailing_slash_policy.bend against
proof/trailing-spec.bend):
trailing_target_spec: the target equalsTrailingSpec.targetfor every mode, method, path and query.trailing_redirect_same_originandtrailing_redirect_reads_only: every target is a same-origin path and answersGETorHEAD.trailing_trim_keeps_root,trailing_trim_targetandtrailing_append_target.
Origin serialization, request field lookup, UTF-8 decoding, Request.method,
string splitting and token validation are shared parsing primitives. The
response specs independently define Vary membership and reflected header
selection.
Verify with:
bun run prove
bun test --timeout 30000 tests/mw-cors.test.ts