Skip to content

Security

A prop value becomes an attribute value, through setAttribute semantics and HTML escaping. There is nothing to escape by hand and no way to forget:

Hostile input, harmless output
// Interpolated values are attribute values, never markup. There is
// nothing to escape by hand and no way to forget.
return alacris.E("x-demo").
Prop("label", `" onload="alert(1)`).
Prop("payload", map[string]string{"html": "</x-demo><script>"})
renders verified by go test
<x-demo
label="&#34; onload=&#34;alert(1)"
payload="{&#34;html&#34;:&#34;\u003c/x-demo\u003e\u003cscript\u003e&#34;}"
></x-demo>
which the browser turns into the real runtime, upgrading that markup

Values are attribute values, never markup.

The JSON prop is the interesting case: quotes inside it are escaped so the attribute cannot end early, and the <script> in the payload is text when it arrives.

alacris itself never parses an interpolated value as HTML either: child bindings become text nodes and attribute bindings go through setAttribute, so the guarantee holds on both sides of the boundary.

Style and Var refuse anything that could end the declaration, end the attribute, or open a comment:

.Var("bg", "red;position:fixed") // ';' is not allowed
.Var("bg", "red/*x*/") // comment markers are not allowed
.Var("bg", "url(javascript:alert(1))")
.Var("bg", "expression(alert(1))")

An error names the character and the property. A sanitizer that silently substitutes a placeholder would give you a page that looks wrong for reasons you cannot see.

Entries are JSON-encoded with Go’s default HTML escaping, so <, > and & become < and friends. A hostile import path cannot end the <script> element.

Tag names are checked against the rule customElements.define enforces, and attribute names against what can appear in a start tag. Both fail the render rather than producing HTML whose structure differs from what you wrote.

  • The body is capped at MaxDetail (64 KiB), enforced with http.MaxBytesReader.
  • Bind decodes with DisallowUnknownFields, so a payload that does not match the declared detail is refused rather than arriving half-filled.
  • Cross-origin posts are rejected by comparing Origin against the request Host. Options.AllowOrigin overrides that when you need to.

Both halves are 18 bytes from crypto/rand, base64url. Long enough that guessing is not a strategy, and generation panics rather than falling back to anything weaker if the OS has no randomness. The client token is compared in constant time, because that comparison is the authorisation decision.

The capability is a cookie, so the browser attaches it to whatever a page can cause. Three things stand in for the secret that used to sit in the request body, and they are described in full on the wire protocol page: SameSite=Lax, a required application/json content type, and the page id as a double-submit token checked against the cookie.

Cross-origin is off unless Options.AllowOrigin names the origins you trust. A credentialed endpoint that reflects whatever origin it is sent is an open door, so the headers that make cross-origin work are only sent for an origin that was allowed, and the response never uses *.

No eval, no new Function, anywhere in the runtime or the client. Two Trusted Types policies are registered: alacris for template parsing, alacris-live for SetHTML.

Content-Security-Policy:
trusted-types alacris alacris-live;
require-trusted-types-for 'script';
script-src 'nonce-{{nonce}}';

Config.Nonce, or templ.WithNonce on the context, puts the nonce on every script tag the library emits.

Patches are not authorised per element. A session can be told to write any prop on any element on its own page. That is the same page the server rendered, so a handler must decide whether an action is permitted before patching, not rely on the page not asking.

Errors do not reach the browser. A handler returning an error logs it and answers 500; the response body is a fixed string. If a user should see something, send a patch saying so in the page’s own terms. It stops internal detail leaking through an error path nobody was thinking about.

The app host binds 127.0.0.1 and rejects a Host header that is not that address. Run also issues a host token the webview presents as an HttpOnly cookie, so another local process that can reach the port cannot create a session. The live cookie is still required. Do not bind 0.0.0.0. Details: Desktop apps.

Security issues in this module go to its security page. For the runtime itself, see alacris.