Sensible defaults for HTTP security headers in Gleam web servers. Covers CSP, HSTS, X-Frame-Options, and more. Override or disable any header as needed.
Inspired by helmet and built on gleam_http, so it works with wisp, mist, or any other compatible server.
gleam add plume@1As use middleware, e.g. with wisp:
import plume
import wisp.{type Request, type Response}
pub fn handle_request(_req: Request) -> Response {
use <- plume.middleware(plume.default())
wisp.ok()
}Or directly on a response, e.g. with mist:
import gleam/bytes_tree
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import mist.{type Connection, type ResponseData}
import plume
pub fn handle_request(_req: Request(Connection)) -> Response(ResponseData) {
response.new(200)
|> response.set_body(mist.Bytes(bytes_tree.from_string("Hello!")))
|> plume.set_headers(plume.default())
}Start from plume.default() and override individual fields with record
update syntax.
To configure a header, pass header-specific options:
import gleam/option.{Some}
import plume
import plume/frame_options
plume.Config(
..plume.default(),
frame_options: Some(frame_options.Deny),
)To disable a header, set its field to None:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), content_security_policy: None)If you'd rather opt in header-by-header instead of starting from defaults,
use plume.new() as your base — it sets no headers.
Each header lives in its own submodule (e.g. plume/content_security_policy,
plume/strict_transport_security) with typed variants for every directive.
Further documentation can be found at https://hexdocs.pm/plume.
plume.default() ships with sensible default values for most of the headers below.
Content-Security-Policy
Default (rendered on one line; wrapped here for readability):
Content-Security-Policy:
default-src 'self';
base-uri 'self';
font-src 'self' https: data:;
form-action 'self';
frame-ancestors 'self';
img-src 'self' data:;
object-src 'none';
script-src 'self';
script-src-attr 'none';
style-src 'self' https: 'unsafe-inline';
upgrade-insecure-requests
Restricts which resources the browser may load for a page; mitigates XSS and data injection. The default locks resources down to the document's own origin, then adds the escape hatches real-world sites tend to need:
script-src 'self'deliberately omits'unsafe-inline'— inline scripts are the primary XSS vector. Use a nonce or hash to allow specific ones.script-src-attr 'none'blocks inline event handlers likeonclick="…".object-src 'none'blocks<object>and<embed>, both historic XSS vectors.base-uri 'self'andform-action 'self'stop injected markup from rebasing relative URLs or hijacking form submissions.frame-ancestors 'self'is the modern CSP equivalent ofX-Frame-Options: SAMEORIGIN. Modern browsers honorframe-ancestorsand ignoreX-Frame-Optionswhen both are present; the latter is kept as a fallback for legacy clients.font-srcandimg-srcallowdata:URIs — inline fonts and images are common, and neither executes scripts in modern browsers. (SVGs loaded via<img>, in particular, don't run their<script>elements.) Tightenfont-srcif you serve untrusted user uploads as fonts.font-srcandstyle-srcallow anyhttps:origin so CDN-hosted assets work without further configuration.style-src 'unsafe-inline'is included because hashing or noncing every inline style is impractical, and inline styles can't execute scripts (though CSS injection has its own narrower risks, like data exfiltration via attribute selectors).upgrade-insecure-requestsrewriteshttp://subresources tohttps://, catching mixed-content bugs without breaking the page.
Customize via plume/content_security_policy:
import gleam/option.{Some}
import plume
import plume/content_security_policy as csp
plume.Config(
..plume.new(),
content_security_policy: Some(csp.Policy([
csp.DefaultSrc([csp.Self]),
csp.BaseUri([csp.Self]),
csp.FontSrc([csp.Self, csp.Scheme("https"), csp.Scheme("data")]),
csp.FormAction([csp.Self]),
csp.FrameAncestors([csp.Self]),
csp.ImgSrc([csp.Self, csp.Scheme("data")]),
csp.ObjectSrc([csp.None]),
csp.ScriptSrc([csp.Self]),
csp.ScriptSrcAttr([csp.None]),
csp.StyleSrc([csp.Self, csp.Scheme("https"), csp.UnsafeInline]),
csp.UpgradeInsecureRequests,
])),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), content_security_policy: None)See the MDN docs.
Cross-Origin-Embedder-Policy
Not set by default.
Controls whether the document can load cross-origin resources that don't
explicitly grant permission via CORS or Cross-Origin-Resource-Policy.
Required alongside Cross-Origin-Opener-Policy to enable cross-origin
isolation, which gates powerful APIs like SharedArrayBuffer and
high-resolution performance.now().
Left unset because require-corp only works if every cross-origin
resource the page loads also opts in, which is too app-specific for a safe
default.
To enable via plume/cross_origin_embedder_policy:
import gleam/option.{Some}
import plume
import plume/cross_origin_embedder_policy as coep
plume.Config(
..plume.new(),
cross_origin_embedder_policy: Some(coep.RequireCorp),
)See the MDN docs.
Cross-Origin-Opener-Policy
Default:
Cross-Origin-Opener-Policy: same-origin
Isolates the browsing context from cross-origin openers and popups. Required
alongside Cross-Origin-Embedder-Policy to enable cross-origin isolation.
Customize via plume/cross_origin_opener_policy:
import gleam/option.{Some}
import plume
import plume/cross_origin_opener_policy as coop
plume.Config(
..plume.new(),
cross_origin_opener_policy: Some(coop.SameOrigin),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), cross_origin_opener_policy: None)See the MDN docs.
Cross-Origin-Resource-Policy
Default:
Cross-Origin-Resource-Policy: same-origin
Tells browsers to refuse cross-origin loads of this resource initiated via
no-cors requests (e.g. <script>, <img>).
Customize via plume/cross_origin_resource_policy:
import gleam/option.{Some}
import plume
import plume/cross_origin_resource_policy as corp
plume.Config(
..plume.new(),
cross_origin_resource_policy: Some(corp.SameOrigin),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), cross_origin_resource_policy: None)See the MDN docs.
Origin-Agent-Cluster
Default:
Origin-Agent-Cluster: ?1
Requests origin-keyed agent clustering, isolating the document from same-site cross-origin documents. May also let the browser give the origin its own process or thread.
Customize via plume/origin_agent_cluster:
import gleam/option.{Some}
import plume
import plume/origin_agent_cluster as oac
plume.Config(
..plume.new(),
origin_agent_cluster: Some(oac.Enabled),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), origin_agent_cluster: None)See the MDN docs.
Permissions-Policy
Not set by default.
Declares which browser features (camera, geolocation, etc.) the document and embedded frames may use. Left unset because the right policy depends entirely on the app's feature surface; configure it once you know yours.
To enable via plume/permissions_policy:
import gleam/option.{Some}
import plume
import plume/permissions_policy as pp
plume.Config(
..plume.new(),
permissions_policy: Some(pp.Policy([
// Empty allowlist disables the feature everywhere.
pp.Camera(pp.Origins([])),
// Allow only the document's own origin.
pp.Geolocation(pp.Origins([pp.Self])),
])),
)See the MDN docs.
Referrer-Policy
Default:
Referrer-Policy: no-referrer
Strips the Referer header from outgoing requests — the most private
option. Prevents leaking sensitive URL contents (session IDs, internal paths)
to third-party sites.
Customize via plume/referrer_policy:
import gleam/option.{Some}
import plume
import plume/referrer_policy as rp
plume.Config(
..plume.new(),
referrer_policy: Some(rp.NoReferrer),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), referrer_policy: None)See the MDN docs.
Strict-Transport-Security
Default:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Tells browsers to access the site only over HTTPS for one year, including
subdomains. One year matches what browsers and the HSTS preload list expect,
and includeSubDomains stops an attacker who can intercept
http://staging.example.com from setting cookies for example.com.
preload is not included by default — submitting a domain to the preload
list is a deliberate, mostly-irreversible commitment that should be made
explicitly.
Customize via plume/strict_transport_security:
import gleam/option.{Some}
import plume
import plume/strict_transport_security as sts
plume.Config(
..plume.new(),
strict_transport_security: Some(sts.IncludeSubDomains(31_536_000)),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), strict_transport_security: None)See the MDN docs.
X-Content-Type-Options
Default:
X-Content-Type-Options: nosniff
Disables MIME type sniffing, reducing exposure to drive-by download attacks and the serving of user-uploaded content under an unexpected MIME type.
Enable via plume/content_type_options:
import gleam/option.{Some}
import plume
import plume/content_type_options as cto
plume.Config(
..plume.new(),
content_type_options: Some(cto.NoSniff),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), content_type_options: None)See the MDN docs.
X-DNS-Prefetch-Control
Default:
X-DNS-Prefetch-Control: off
Disables DNS prefetching to avoid leaking which resources a page references to the user's DNS resolver.
Customize via plume/dns_prefetch_control:
import gleam/option.{Some}
import plume
import plume/dns_prefetch_control as dpc
plume.Config(
..plume.new(),
dns_prefetch_control: Some(dpc.Off),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), dns_prefetch_control: None)See the MDN docs.
X-Download-Options
Default:
X-Download-Options: noopen
IE 8 only: forces downloaded files to be saved before opening, preventing execution of HTML in the site's origin.
Enable via plume/download_options:
import gleam/option.{Some}
import plume
import plume/download_options as do
plume.Config(
..plume.new(),
download_options: Some(do.NoOpen),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), download_options: None)See the Microsoft docs.
X-Frame-Options
Default:
X-Frame-Options: SAMEORIGIN
Restricts framing to same-origin pages, mitigating clickjacking. The CSP
frame-ancestors directive is the modern replacement; this header is kept
as a fallback for legacy browsers that don't honor frame-ancestors.
Customize via plume/frame_options:
import gleam/option.{Some}
import plume
import plume/frame_options
plume.Config(
..plume.new(),
frame_options: Some(frame_options.SameOrigin),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), frame_options: None)See the MDN docs.
X-Permitted-Cross-Domain-Policies
Default:
X-Permitted-Cross-Domain-Policies: none
Blocks Adobe Flash and Acrobat cross-domain policy files from being loaded on the site.
Customize via plume/permitted_cross_domain_policies:
import gleam/option.{Some}
import plume
import plume/permitted_cross_domain_policies as pcdp
plume.Config(
..plume.new(),
permitted_cross_domain_policies: Some(pcdp.None),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), permitted_cross_domain_policies: None)See the MDN docs.
X-XSS-Protection
Default:
X-XSS-Protection: 0
Disables the legacy XSS auditor in older browsers — its bypasses could
become XSS sinks of their own, and modern browsers have removed it. A strong
Content-Security-Policy is the modern replacement, so the default of 0 is
the recommended value.
Customize via plume/xss_protection:
import gleam/option.{Some}
import plume
import plume/xss_protection as xp
plume.Config(
..plume.new(),
xss_protection: Some(xp.Disabled),
)To remove the header:
import gleam/option.{None}
import plume
plume.Config(..plume.default(), xss_protection: None)See the MDN docs.
Plume sets every configured header, replacing any existing value. If you also
use Wisp's
content_security_policy_protection
middleware — which emits a per-request nonce-based strict CSP — its header
will be overridden by Plume's CSP unless you either:
-
Place Wisp's CSP middleware outside Plume's so it runs last and wins on the
Content-Security-Policyheader:import plume import wisp.{type Request, type Response} pub fn handle_request(_req: Request) -> Response { use _csp_nonce <- wisp.content_security_policy_protection() use <- plume.middleware(plume.default()) wisp.ok() }
-
Or disable Plume's CSP so Wisp's is the only one set:
import gleam/option.{None} import plume plume.Config(..plume.default(), content_security_policy: None)
The same ordering rule applies to any other middleware that sets headers Plume also configures.
gleam build # Compile the project
gleam test # Run the tests