Skip to content

Encode redirect URL as a safe JS string literal#23

Open
andriytyurnikov wants to merge 1 commit intostarfederation:mainfrom
rubakas:feature/redirect-url-escape
Open

Encode redirect URL as a safe JS string literal#23
andriytyurnikov wants to merge 1 commit intostarfederation:mainfrom
rubakas:feature/redirect-url-escape

Conversation

@andriytyurnikov
Copy link
Copy Markdown

Summary

Fixes a JS string-literal injection in ServerSentEventGenerator#redirect. The URL was previously interpolated raw inside a single-quoted JS string:

def redirect(url)
  execute_script %(setTimeout(() => { window.location = '#{url}' }))
end

A ' in url broke out of the literal, letting attacker-influenced fragments execute as JS.

Realistic attack pattern

datastar.redirect("/dashboard?ref=#{params[:ref]}")

This is a Rails idiom — redirect_to accepts the same shape and is safe — so developers reasonably assume Datastar's redirect is also safe. With attacker-controlled params[:ref], pre-fix the rendered JS becomes:

```js
window.location = '/dashboard?ref=x'); alert('credential-theft'); (''
```

→ executes the alert.

Fix

Encode the URL with JSON.generate(url.to_s, ascii_only: true, escape_slash: true). The output is a properly-quoted JS string literal that:

  • escapes \", \\, and control characters,
  • escapes U+2028 / U+2029 (which terminate JS string literals even when delimited by \" or '),
  • escapes /\\/ so a </script> substring in the URL can't prematurely close the surrounding <script> tag during HTML parsing. The HTML parser does not recognize <\\/script> as the end tag, while \\/ is a no-op inside a JS string literal.

Post-fix output for the same attack:

```js
window.location = "\/dashboard?ref=x'); alert('credential-theft'); ('"
```

The breakout payload sits as inert content inside the double-quoted literal. Just a normal redirect to a weird URL.

Behavior change

window.location is now wrapped in double quotes with JSON-style escapes instead of single quotes. Functionally identical for safe URLs — the runtime navigation target is the same. One existing snapshot test was updated to reflect the new output format.

Tests

6 new specs covering single quotes, double quotes, backslashes, </script> breakout, U+2028, and U+2029. Full suite: 106 examples, 0 failures.

Test plan

  • CI green
  • Existing redirect regression test still passes (with updated snapshot)
  • New attack-vector specs all pass

Pre-fix #redirect interpolated the URL raw inside a single-quoted JS
string:

  window.location = '#{url}'

A ' in url broke out of the literal, letting attacker-influenced
fragments execute as JS. The classic vulnerable pattern:

  datastar.redirect("/page?ref=#{params[:ref]}")

is a Rails idiom that's safe under Rails' own redirect_to, so the
mismatch is a footgun.

Fix: encode the URL with JSON.generate(ascii_only: true,
escape_slash: true). The output is a properly-quoted JS string
literal that:

  - escapes ", \, and control characters,
  - escapes U+2028 / U+2029 (which terminate JS string literals
    even when delimited by " or '),
  - escapes / to \/ so a </script> substring in the URL can't
    prematurely close the surrounding <script> tag during HTML
    parsing (the parser does not recognize <\/script> as the end
    tag, while \/ is a no-op inside a JS string literal).

Output format change: window.location is now wrapped in double
quotes with JSON-style escapes instead of single quotes. Behavior
at runtime is unchanged for safe URLs.

6 new specs cover single quotes, double quotes, backslashes,
</script> breakout, U+2028, and U+2029.
@andriytyurnikov
Copy link
Copy Markdown
Author

Upstream CI hasn't fired on this PR (workflow is on: push only). Fork CI run on the same commit, green: https://github.com/rubakas/datastar-ruby/actions/runs/24991335240 (Ruby 4.0 on Linux + full Datastar SDK conformance suite). 106 examples, 0 failures — including 6 attack-vector specs for single quotes, double quotes, backslashes, </script> breakout, U+2028, and U+2029.

@andriytyurnikov andriytyurnikov deleted the feature/redirect-url-escape branch April 27, 2026 11:35
@andriytyurnikov andriytyurnikov restored the feature/redirect-url-escape branch April 27, 2026 11:51
@andriytyurnikov andriytyurnikov deleted the feature/redirect-url-escape branch April 27, 2026 11:55
@andriytyurnikov andriytyurnikov restored the feature/redirect-url-escape branch April 27, 2026 12:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant