The `view!` macro's static ("inert") HTML generator grouped `<textarea>`
with the true raw-text elements `<script>` and `<style>`, emitting its
children without HTML escaping.
Unlike `<script>`/`<style>`, `<textarea>` is *escapable* raw text per the
HTML spec, so its content must be escaped. Leaving it unescaped diverged
from the runtime render path (which escapes textarea children) and could
emit malformed markup for static content containing `<`, `>`, or `&`.
Drop `textarea` from the raw-text exemption at all three inert-path sites
(element, SVG element, and the node dispatcher) so its children are
escaped like every other normal element.
Adds SSR integration tests covering both the inert (static) textarea path
and the runtime (dynamic) textarea path.
(cherry picked from commit fce59d4766)
`<textarea>` was declared with `ESCAPE_CHILDREN: false`, lumping it in
with the true raw-text elements `<script>`, `<style>`, and `<noscript>`.
Per the HTML spec, `<textarea>` content is *escapable* raw text: it
cannot contain a literal `<` or `&`, and any case-insensitive
`</textarea>` closes the element. Treating it as non-escapable meant an
untrusted string flowing into a textarea body was emitted verbatim, so
input such as `</textarea><script>alert('xss')</script>` broke out of
the element and injected live markup — an XSS sink.
Flip `ESCAPE_CHILDREN` to `true` for the `textarea` row so its children
are HTML-escaped like every other normal element.
Adds an SSR regression test asserting the breakout payload is escaped.
(cherry picked from commit c7acf0cb23)
`RenderHtml::to_html_with_buf` for primitive types ignored the `escape`
argument and wrote the value directly with `write!(buf, "{}", self)`.
For numeric / `bool` / `IpAddr` / `NonZero*` primitives this is harmless:
their `Display` output is a syntactic subset of HTML text. `char`,
however, can be any Unicode scalar value, including `<`, `>`, `&`, `"`,
and `'`. Writing an untrusted `char` into an element body without
escaping is an HTML-injection / XSS sink, and it contradicts the
documented contract of the `escape` flag (already honored for
`&str` / `String` / `Cow<str>` / `Arc<str>`).
Parametrize `render_primitive!` with an escaping flag. `char` is now
rendered through `html_escape::encode_text` when the enclosing element
escapes its children; in a raw-text context (`escape == false`) it is
still written verbatim. All other primitives keep their previous,
allocation-free direct write, so their output is byte-identical.
Adds regression tests covering the escaped, raw-text, and numeric cases.
(cherry picked from commit b84df90e44)
`SsrSharedContext::pending_data` and `AsyncDataStream::poll_next` emit each
registered error into a `<script>` block as `[<boundary>, <id>, {:?}]` where
the third field is the error's `Display` output. Rust's `{:?}` only escapes
`"` and `\` — it does not escape `<`. The matching resolved-resource path is
already careful to do `data.replace('<', "\\u003c")` before formatting, but
the error path was not, so any `</script>` substring that ends up inside an
error message closes the surrounding script tag in the browser's HTML
tokenizer and lets the following bytes be parsed as new HTML.
Reproducer:
use hydration_context::{SerializedDataId, SharedContext, SsrSharedContext};
use throw_error::{Error, ErrorId};
use std::fmt;
use futures::{StreamExt, executor::block_on};
#[derive(Debug)]
struct E(&'static str); impl fmt::Display for E {
fn fmt( & self, f: & mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for E {}
let ctx = SsrSharedContext::new();
ctx.register_error(
SerializedDataId::new(0),
ErrorId::from(0_usize),
Error::from(E("boom</script><script>alert('pwned')</script><script>")),
);
let mut s = ctx.pending_data().unwrap();
let chunk = block_on(s.next()).unwrap();
// Before the fix, `chunk` contains a literal `</script>` substring
// that closes the enclosing <script> in the streamed HTML.
Realistic input sources: any user-controllable text that can flow into an
`Error` body (route parameters, deserialization errors that echo the input,
server-fn error messages, etc.) becomes an XSS sink.
Fix: before formatting the error message, every `<` character in it is
rewritten to the six-character sequence `<` (backslash + u + 0 + 0 + 3
+ c) — the same transformation the resolved-resource path already applies
in `ResolvedData::write_to_buf` and `AsyncDataStream`. Concretely:
let msg = error.2.to_string().replace('<', "\\u003c");
write!(out, "[{}, {}, {:?}],", error.0.0, error.1, msg);
The HTML tokenizer scanning the resulting script body no longer sees a
literal `<`, so it cannot recognise `</script>` and the script tag stays
open. The JS engine then parses the string literal and a downstream JSON
unescape turns `<` back into `<` for the consumer, matching the
existing resource-data behaviour.
Both write sites are patched (initial chunk emission and the streaming
`__SERIALIZED_ERRORS.push(...)` path) and a regression test for each is
added — these are also the first tests this crate has ever shipped.
(cherry picked from commit 6a59c51641)
When the pattern iterator is exhausted but the URL still has characters
remaining (not a slash), the loop was breaking out instead of returning
None. This caused StaticSegment(fo) to falsely match /foobar.
Changed the break to return None so only exact segment boundaries match.
wretry.action does not forward the wrapped action's `with` inputs, so
pnpm/action-setup ran without version: 8 and installed the latest pnpm,
which requires Node.js 22.13 and failed on the Node 20 runner. Call
actions/checkout, actions/setup-node, pnpm/action-setup and
denoland/setup-deno directly so their inputs take effect.
Wandalen/wretry.action only supports node and docker actions and errors
on composite actions. Use dtolnay/rust-toolchain, cargo-bins/cargo-binstall
and taiki-e/install-action directly; they already retry their downloads
internally (curl --retry 10 and retry loops).