concerns_on_rails 1.11.2 → 1.12.1
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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +85 -1
- data/lib/concerns_on_rails/controllers/secure_headable.rb +105 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/sanitizable.rb +148 -0
- data/lib/concerns_on_rails/support/html_sanitizers.rb +63 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +3 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56664d3e955f53dbeec1d586c31b57c860a208cedff76990236a728de1c6f9ec
|
|
4
|
+
data.tar.gz: 6a427597a32d8a2d848d2f51ed9cf45d6a3ea41dd9ea77c2b215b4a66846170e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b34bc02259d6a33a0d1fa1dac2f2e71473cc543515df2088766b736eed4cdc8e9925616cca6397a5318d22bdcafbf910e8bea6ea9f93e1de4746613f3aa747b2
|
|
7
|
+
data.tar.gz: 52e3f5a2dda49da303b118a4962ca479bb43b628d363ff133c23939fc523235a01065441cea4002dccd33130ace25149da275ddb9839dc864b2e442377f88a28
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.12.1 (2026-06-06)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Models::Sanitizable**: opt-in HTML sanitization for string attributes — defense-in-depth on top of Rails' default output escaping (not a replacement for it). `sanitizable :body, with: :safe_list` is **non-destructive by default** (`on: :read`): it adds a `sanitized_<field>` reader and leaves the stored column raw. `on: :write` is an explicit, lossy opt-in that overwrites the column in `before_validation` (so presence/length validations see the cleaned value). Presets: `:strip` (remove all tags), `:safe_list` (Rails' allow-list), `:no_links`, `:none`, plus custom `Array` / `Hash` (`{ tags:, attributes: }`) allow-lists and a `Proc` escape hatch. Schema-checked via `ColumnGuard`. Zero new runtime dependencies.
|
|
7
|
+
- **Controllers::SecureHeadable**: modern security response headers + a thin delegation to Rails' native Content-Security-Policy DSL. `secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :no_cross_domain, :disable_legacy_xss` (and custom `"Header-Name" => "value"` pairs), applied in an `after_action`. `content_security_policy_for(report_only:, only:, except:, …, &block)` forwards straight to Rails. Ships `X-XSS-Protection: 0` (the only correct modern value) and deliberately does **not** scrub params. Zero new runtime dependencies.
|
|
8
|
+
- **Support::HtmlSanitizers**: shared, lazily-memoized, feature-detected (HTML5/HTML4) `FullSanitizer` / `SafeListSanitizer` / `LinkSanitizer` instances backing `Models::Sanitizable`, reusing the `rails-html-sanitizer` that already ships with Action View.
|
|
9
|
+
|
|
10
|
+
### Notes
|
|
11
|
+
- All changes are additive and backward-compatible. `ConcernsOnRails::Sanitizable` is aliased to `Models::Sanitizable`; the controller concern stays namespace-only (`ConcernsOnRails::Controllers::SecureHeadable`), matching the existing controller concerns.
|
|
12
|
+
- These features were first tagged as `1.12.0`, but that tag's CI lint failed before the RubyGems publish step, so it was never released. `1.12.1` is the first published version carrying them.
|
|
13
|
+
|
|
3
14
|
## 1.11.2 (2026-06-06)
|
|
4
15
|
|
|
5
16
|
### Added
|
data/README.md
CHANGED
|
@@ -40,6 +40,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
40
40
|
- [Stateable](#-stateable) — lightweight string-backed state machine
|
|
41
41
|
- [Addressable](#-addressable) — postal address normalization + format validation
|
|
42
42
|
- [Taggable](#-taggable) — lightweight tagging over a single column
|
|
43
|
+
- [Sanitizable](#-sanitizable) — opt-in HTML sanitization (XSS defense-in-depth)
|
|
43
44
|
- **Controller concerns**
|
|
44
45
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
45
46
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
@@ -47,6 +48,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
47
48
|
- [Respondable](#-respondable) — standardized JSON envelopes
|
|
48
49
|
- [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
|
|
49
50
|
- [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
|
|
51
|
+
- [SecureHeadable](#-secureheadable) — security response headers + native CSP DSL
|
|
50
52
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
51
53
|
- [Development](#-development)
|
|
52
54
|
- [Contributing](#-contributing)
|
|
@@ -56,7 +58,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
56
58
|
|
|
57
59
|
## ✨ Why this gem?
|
|
58
60
|
|
|
59
|
-
- **
|
|
61
|
+
- **Sixteen model concerns + seven controller concerns**, all production-ready
|
|
60
62
|
- **One include, one macro** — no boilerplate, no glue code
|
|
61
63
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
62
64
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -827,6 +829,48 @@ Article.all_tags # => sorted unique tags in use
|
|
|
827
829
|
|
|
828
830
|
---
|
|
829
831
|
|
|
832
|
+
## 🧼 Sanitizable
|
|
833
|
+
|
|
834
|
+
Opt-in HTML sanitization for string attributes — **defense-in-depth, not a replacement for Rails' default output escaping** (`<%= %>` already escapes). Reach for it on the rare column you render as trusted HTML (`raw` / `html_safe`) or that must stay plain text. Zero extra dependencies — it uses the `rails-html-sanitizer` that already ships with Action View.
|
|
835
|
+
|
|
836
|
+
```ruby
|
|
837
|
+
class Article < ApplicationRecord
|
|
838
|
+
include ConcernsOnRails::Sanitizable
|
|
839
|
+
|
|
840
|
+
# DEFAULT (on: :read) — non-destructive. The column stays raw; a
|
|
841
|
+
# `sanitized_<field>` reader returns the cleaned value:
|
|
842
|
+
sanitizable :body, with: :safe_list # => article.sanitized_body
|
|
843
|
+
sanitizable :summary, with: :strip # => article.sanitized_summary
|
|
844
|
+
sanitizable :body, with: { tags: %w[b i a], attributes: %w[href] }
|
|
845
|
+
|
|
846
|
+
# EXPLICIT destructive opt-in — for plain-text-only columns only:
|
|
847
|
+
sanitizable :title, with: :strip, on: :write # overwrites in before_validation
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
article = Article.new(body: "<b>Hi</b><script>alert(1)</script>")
|
|
851
|
+
article.body # => "<b>Hi</b><script>alert(1)</script>" (raw, intact)
|
|
852
|
+
article.sanitized_body # => "<b>Hi</b>alert(1)" (script tag dropped)
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
**Presets** (`with:`)
|
|
856
|
+
|
|
857
|
+
| Preset | Behavior |
|
|
858
|
+
|--------------|------------------------------------------------------------------------|
|
|
859
|
+
| `:strip` | Remove all tags, keep inner text (the default). |
|
|
860
|
+
| `:safe_list` | Rails' allow-list: keep formatting tags, drop `<script>` / `<iframe>`. |
|
|
861
|
+
| `:no_links` | Strip only `<a>` tags, keep their text. |
|
|
862
|
+
| `:none` | No-op (declare the field / reader without transforming). |
|
|
863
|
+
| `Array` | Custom tag allow-list, e.g. `with: %w[b i a]`. |
|
|
864
|
+
| `Hash` | `{ tags: [...], attributes: [...] }` allow-list. |
|
|
865
|
+
| `Proc` | Used as-is (you own the non-String guard). |
|
|
866
|
+
|
|
867
|
+
**Notes**
|
|
868
|
+
- `on: :read` (default) is **non-destructive**: it adds a `sanitized_<field>` reader and leaves the stored column untouched.
|
|
869
|
+
- `on: :write` overwrites the column in `before_validation` — **lossy and irreversible** (never use it on code, Markdown, math, or prices), and bypassed by `update_column` / `update_all` / raw SQL.
|
|
870
|
+
- For full user-authored rich text, prefer [Action Text](https://guides.rubyonrails.org/action_text_overview.html).
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
830
874
|
# 🎮 Controller Concerns
|
|
831
875
|
|
|
832
876
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1050,6 +1094,46 @@ GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=
|
|
|
1050
1094
|
|
|
1051
1095
|
---
|
|
1052
1096
|
|
|
1097
|
+
## 🛡️ SecureHeadable
|
|
1098
|
+
|
|
1099
|
+
Modern security response headers + a thin wrapper over Rails' native Content-Security-Policy DSL. Defense-in-depth on top of output escaping — it does **not** scrub request params and never re-enables the deprecated X-XSS-Protection auditor. Zero extra dependencies.
|
|
1100
|
+
|
|
1101
|
+
```ruby
|
|
1102
|
+
class ApplicationController < ActionController::Base
|
|
1103
|
+
include ConcernsOnRails::Controllers::SecureHeadable
|
|
1104
|
+
|
|
1105
|
+
# Preset headers, plus any custom "Header-Name" => "value" pairs:
|
|
1106
|
+
secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
|
|
1107
|
+
secure_headers "Permissions-Policy" => "geolocation=()"
|
|
1108
|
+
|
|
1109
|
+
# Delegates to Rails' native CSP DSL — roll out report-only FIRST:
|
|
1110
|
+
content_security_policy_for(report_only: true) do |policy|
|
|
1111
|
+
policy.default_src :self
|
|
1112
|
+
policy.script_src :self
|
|
1113
|
+
policy.object_src :none
|
|
1114
|
+
end
|
|
1115
|
+
end
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
**Header presets** (`secure_headers`)
|
|
1119
|
+
|
|
1120
|
+
| Preset | Header |
|
|
1121
|
+
|-----------------------|--------------------------------------------------------|
|
|
1122
|
+
| `:nosniff` | `X-Content-Type-Options: nosniff` |
|
|
1123
|
+
| `:sameorigin_frame` | `X-Frame-Options: SAMEORIGIN` |
|
|
1124
|
+
| `:deny_frame` | `X-Frame-Options: DENY` |
|
|
1125
|
+
| `:no_referrer_leak` | `Referrer-Policy: strict-origin-when-cross-origin` |
|
|
1126
|
+
| `:no_cross_domain` | `X-Permitted-Cross-Domain-Policies: none` |
|
|
1127
|
+
| `:disable_legacy_xss` | `X-XSS-Protection: 0` (the only correct modern value) |
|
|
1128
|
+
|
|
1129
|
+
**Notes**
|
|
1130
|
+
- Headers are applied in an `after_action`, so they reinforce Rails' middleware defaults; later `secure_headers` declarations win on a colliding name.
|
|
1131
|
+
- `content_security_policy_for` forwards `report_only:` and per-action `only:` / `except:` / `if:` / `unless:` straight to Rails — it never re-implements CSP. Per-controller CSP overrides the global initializer for that controller.
|
|
1132
|
+
- CSP nonce generation (`content_security_policy_nonce_generator`) is app-wide initializer config and intentionally stays out of the concern.
|
|
1133
|
+
- These headers mitigate clickjacking / MIME-sniffing and (via CSP) XSS as **defense-in-depth** — output escaping remains the primary defense.
|
|
1134
|
+
|
|
1135
|
+
---
|
|
1136
|
+
|
|
1053
1137
|
## 🗂️ Module paths & namespacing
|
|
1054
1138
|
|
|
1055
1139
|
Every concern is available under two paths:
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Controllers
|
|
5
|
+
# Adds modern security response headers and wires Rails' native
|
|
6
|
+
# Content-Security-Policy DSL. Defense-in-depth on top of output escaping —
|
|
7
|
+
# this does NOT scrub request params (context-blind and lossy) and never
|
|
8
|
+
# re-enables the deprecated X-XSS-Protection auditor.
|
|
9
|
+
#
|
|
10
|
+
# class ApplicationController < ActionController::Base
|
|
11
|
+
# include ConcernsOnRails::Controllers::SecureHeadable
|
|
12
|
+
#
|
|
13
|
+
# # Apply preset headers, plus any custom "Header-Name" => "value" pairs:
|
|
14
|
+
# secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
|
|
15
|
+
# secure_headers "Permissions-Policy" => "geolocation=()"
|
|
16
|
+
#
|
|
17
|
+
# # Delegates to Rails' native CSP DSL — roll out report-only FIRST:
|
|
18
|
+
# content_security_policy_for(report_only: true) do |policy|
|
|
19
|
+
# policy.default_src :self
|
|
20
|
+
# policy.script_src :self
|
|
21
|
+
# policy.object_src :none
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# The headers mitigate clickjacking / MIME-sniffing and (via CSP) XSS as
|
|
26
|
+
# defense-in-depth — they are NOT a standalone XSS fix; output escaping
|
|
27
|
+
# remains the primary defense. Per-controller CSP overrides the global
|
|
28
|
+
# initializer for that controller. CSP nonce generation
|
|
29
|
+
# (content_security_policy_nonce_generator / _nonce_directives) is app-wide
|
|
30
|
+
# initializer configuration and intentionally stays out of this concern.
|
|
31
|
+
#
|
|
32
|
+
# Header presets (the `secure_headers` arguments):
|
|
33
|
+
# :nosniff — X-Content-Type-Options: nosniff
|
|
34
|
+
# :sameorigin_frame — X-Frame-Options: SAMEORIGIN
|
|
35
|
+
# :deny_frame — X-Frame-Options: DENY
|
|
36
|
+
# :no_referrer_leak — Referrer-Policy: strict-origin-when-cross-origin
|
|
37
|
+
# :no_cross_domain — X-Permitted-Cross-Domain-Policies: none
|
|
38
|
+
# :disable_legacy_xss — X-XSS-Protection: 0 (the only correct modern value)
|
|
39
|
+
module SecureHeadable
|
|
40
|
+
extend ActiveSupport::Concern
|
|
41
|
+
|
|
42
|
+
# Frozen, string-only header presets, each "Header-Name" => "value".
|
|
43
|
+
# :disable_legacy_xss emits "0" deliberately — the legacy browser XSS
|
|
44
|
+
# auditor was itself exploitable and is gone from modern browsers
|
|
45
|
+
# (Rails 7+ ships "0"), so "0" is the only correct value.
|
|
46
|
+
PRESETS = {
|
|
47
|
+
nosniff: %w[X-Content-Type-Options nosniff],
|
|
48
|
+
sameorigin_frame: %w[X-Frame-Options SAMEORIGIN],
|
|
49
|
+
deny_frame: %w[X-Frame-Options DENY],
|
|
50
|
+
no_referrer_leak: %w[Referrer-Policy strict-origin-when-cross-origin],
|
|
51
|
+
no_cross_domain: %w[X-Permitted-Cross-Domain-Policies none],
|
|
52
|
+
disable_legacy_xss: %w[X-XSS-Protection 0]
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
included do
|
|
56
|
+
class_attribute :secure_headable_headers, instance_accessor: false, default: {}
|
|
57
|
+
# after_action (not before) so the headers survive render and reinforce
|
|
58
|
+
# Rails' middleware defaults when a name collides.
|
|
59
|
+
after_action :apply_secure_headers
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class_methods do
|
|
63
|
+
# Register preset headers (by symbol) plus optional custom
|
|
64
|
+
# "Header-Name" => "value" pairs. Later declarations win on collision.
|
|
65
|
+
def secure_headers(*presets, **custom)
|
|
66
|
+
resolved = presets.to_h do |key|
|
|
67
|
+
PRESETS.fetch(key) do
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"ConcernsOnRails::Controllers::SecureHeadable: unknown preset '#{key}'. " \
|
|
70
|
+
"Valid presets: #{PRESETS.keys.join(', ')}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
self.secure_headable_headers =
|
|
75
|
+
secure_headable_headers.merge(resolved).merge(custom.transform_keys(&:to_s))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Thin pass-through to Rails' native CSP DSL — never re-implement CSP.
|
|
79
|
+
# Forwards per-action conditions (only: / except: / if: / unless:) and
|
|
80
|
+
# the policy block straight through.
|
|
81
|
+
def content_security_policy_for(report_only: false, **action_opts, &block)
|
|
82
|
+
unless respond_to?(:content_security_policy)
|
|
83
|
+
raise ArgumentError,
|
|
84
|
+
"ConcernsOnRails::Controllers::SecureHeadable: CSP requires " \
|
|
85
|
+
"ActionController::ContentSecurityPolicy (Rails 5.2+)"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if report_only
|
|
89
|
+
content_security_policy_report_only(true, **action_opts, &block)
|
|
90
|
+
else
|
|
91
|
+
content_security_policy(**action_opts, &block)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Public so subclasses can override; guarded exactly like Paginatable so
|
|
97
|
+
# it no-ops cleanly when there is no response object.
|
|
98
|
+
def apply_secure_headers
|
|
99
|
+
return unless respond_to?(:response) && response
|
|
100
|
+
|
|
101
|
+
self.class.secure_headable_headers.each { |name, value| response.set_header(name, value) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/html_sanitizers"
|
|
3
|
+
|
|
4
|
+
module ConcernsOnRails
|
|
5
|
+
module Models
|
|
6
|
+
# Opt-in HTML sanitization for string attributes — defense-in-depth, NOT a
|
|
7
|
+
# substitute for Rails' default output escaping.
|
|
8
|
+
#
|
|
9
|
+
# ESCAPE-FIRST: Rails already HTML-escapes `<%= record.body %>` via
|
|
10
|
+
# SafeBuffer, so for ordinary columns you need nothing here. Reach for this
|
|
11
|
+
# concern only for the rare column you render as trusted HTML
|
|
12
|
+
# (`raw` / `html_safe`) or that must be kept plain text. For full
|
|
13
|
+
# user-authored rich text, prefer Action Text.
|
|
14
|
+
#
|
|
15
|
+
# class Article < ApplicationRecord
|
|
16
|
+
# include ConcernsOnRails::Models::Sanitizable
|
|
17
|
+
#
|
|
18
|
+
# # DEFAULT (on: :read) — non-destructive. The stored column stays raw;
|
|
19
|
+
# # a `sanitized_<field>` reader returns the cleaned value:
|
|
20
|
+
# sanitizable :body, with: :safe_list # => article.sanitized_body
|
|
21
|
+
# sanitizable :summary, with: :strip # => article.sanitized_summary
|
|
22
|
+
# sanitizable :body, with: { tags: %w[b i a], attributes: %w[href] }
|
|
23
|
+
#
|
|
24
|
+
# # EXPLICIT destructive opt-in — for plain-text-only columns only:
|
|
25
|
+
# sanitizable :title, with: :strip, on: :write # overwrites in before_validation
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# WARNING: `on: :write` is lossy and irreversible — never use it on code,
|
|
29
|
+
# Markdown, math, or anything where `<` / `>` are legitimate. It is also
|
|
30
|
+
# bypassed by `update_column` / `update_all` / raw SQL, which skip
|
|
31
|
+
# callbacks. The non-destructive `on: :read` default is preferred.
|
|
32
|
+
#
|
|
33
|
+
# Presets (the `with:` argument):
|
|
34
|
+
# :strip — remove all tags, keep inner text (the default)
|
|
35
|
+
# :safe_list — Rails' allow-list: keep formatting tags, drop <script> etc.
|
|
36
|
+
# :no_links — strip only <a> tags, keep their text
|
|
37
|
+
# :none — no-op (declare the field / reader without transforming)
|
|
38
|
+
# Array — custom tag allow-list, e.g. with: %w[b i a]
|
|
39
|
+
# Hash — { tags: [...], attributes: [...] } allow-list
|
|
40
|
+
# Proc — used as-is (the caller owns the non-String guard)
|
|
41
|
+
module Sanitizable
|
|
42
|
+
extend ActiveSupport::Concern
|
|
43
|
+
|
|
44
|
+
# Frozen, string-safe lambdas — non-String values pass through untouched,
|
|
45
|
+
# exactly like Normalizable::PRESETS. Each resolves its sanitizer through
|
|
46
|
+
# the shared Support helper (fully qualified, so there is no lexical-scope
|
|
47
|
+
# dependency and libgumbo is probed once, not per access) and always
|
|
48
|
+
# returns a plain String via #to_s, so a SafeBuffer is never persisted.
|
|
49
|
+
PRESETS = {
|
|
50
|
+
strip: ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.full.sanitize(v).to_s : v },
|
|
51
|
+
safe_list: ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.safe.sanitize(v).to_s : v },
|
|
52
|
+
no_links: ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.link.sanitize(v).to_s : v },
|
|
53
|
+
none: ->(v) { v }
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
included do
|
|
57
|
+
# field => { sanitizer: <lambda>, on: :read|:write }
|
|
58
|
+
class_attribute :sanitizable_rules, instance_accessor: false, default: {}
|
|
59
|
+
before_validation :apply_sanitizations
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class_methods do
|
|
63
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
64
|
+
|
|
65
|
+
# Declare which string fields to sanitize, how, and when.
|
|
66
|
+
# sanitizable :body, with: :safe_list # non-destructive reader
|
|
67
|
+
# sanitizable :title, with: :strip, on: :write # destructive overwrite
|
|
68
|
+
def sanitizable(*fields, with: :strip, on: :read)
|
|
69
|
+
raise ArgumentError, "ConcernsOnRails::Models::Sanitizable: at least one field is required" if fields.empty?
|
|
70
|
+
|
|
71
|
+
unless %i[read write].include?(on)
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"ConcernsOnRails::Models::Sanitizable: :on must be :read or :write, got #{on.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sanitizer = resolve_sanitizer(with)
|
|
77
|
+
ensure_columns!("ConcernsOnRails::Models::Sanitizable", fields)
|
|
78
|
+
|
|
79
|
+
fields.each do |field|
|
|
80
|
+
key = field.to_sym
|
|
81
|
+
self.sanitizable_rules = sanitizable_rules.merge(key => { sanitizer: sanitizer, on: on })
|
|
82
|
+
|
|
83
|
+
# Non-destructive default: a clean reader, with the raw column intact.
|
|
84
|
+
define_method("sanitized_#{field}") { sanitizer.call(self[key]) } if on == :read
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class_methods do # rubocop:disable Metrics/BlockLength
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Accepts a preset Symbol, a Proc (used as-is), an Array (custom tags
|
|
93
|
+
# allow-list), or a Hash with :tags / :attributes.
|
|
94
|
+
def resolve_sanitizer(with)
|
|
95
|
+
case with
|
|
96
|
+
when Symbol then preset_sanitizer(with)
|
|
97
|
+
when Proc then with
|
|
98
|
+
when Array then allowlist_sanitizer(tags: with.map(&:to_s))
|
|
99
|
+
when Hash then hash_allowlist_sanitizer(with)
|
|
100
|
+
else
|
|
101
|
+
raise ArgumentError,
|
|
102
|
+
"ConcernsOnRails::Models::Sanitizable: :with must be a preset symbol, an allow-list " \
|
|
103
|
+
"(Array or Hash), or a Proc/lambda, got #{with.class}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def preset_sanitizer(name)
|
|
108
|
+
PRESETS.fetch(name) do
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"ConcernsOnRails::Models::Sanitizable: unknown preset '#{name}'. " \
|
|
111
|
+
"Valid presets: #{PRESETS.keys.join(', ')}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def hash_allowlist_sanitizer(opts)
|
|
116
|
+
unknown = opts.keys - %i[tags attributes]
|
|
117
|
+
unless unknown.empty?
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
"ConcernsOnRails::Models::Sanitizable: allow-list keys must be :tags / :attributes, got #{unknown.inspect}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
allowlist_sanitizer(tags: opts[:tags]&.map(&:to_s), attributes: opts[:attributes]&.map(&:to_s))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Builds a SafeListSanitizer lambda restricted to the given tags/attributes.
|
|
126
|
+
def allowlist_sanitizer(tags: nil, attributes: nil)
|
|
127
|
+
options = {}
|
|
128
|
+
options[:tags] = tags if tags
|
|
129
|
+
options[:attributes] = attributes if attributes
|
|
130
|
+
->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.safe.sanitize(v, **options).to_s : v }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Only fields declared with on: :write are mutated; on: :read fields keep
|
|
135
|
+
# their raw column value and are exposed through their sanitized_ reader.
|
|
136
|
+
def apply_sanitizations
|
|
137
|
+
self.class.sanitizable_rules.each do |field, rule|
|
|
138
|
+
next unless rule[:on] == :write
|
|
139
|
+
|
|
140
|
+
value = self[field]
|
|
141
|
+
next if value.nil?
|
|
142
|
+
|
|
143
|
+
self[field] = rule[:sanitizer].call(value) # plain String, never a SafeBuffer
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "rails-html-sanitizer"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# rails-html-sanitizer ships transitively via actionview in every Rails app
|
|
7
|
+
# (actionview -> rails-html-sanitizer -> loofah), so this require is purely
|
|
8
|
+
# defensive for the rare host that pins an unusually old actionview. If the
|
|
9
|
+
# library is genuinely absent, referencing a sanitizer below raises a clear
|
|
10
|
+
# NameError at first use rather than at gem load.
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module ConcernsOnRails
|
|
14
|
+
module Support
|
|
15
|
+
# Memoized, feature-detected HTML sanitizer instances shared by the
|
|
16
|
+
# sanitizing concerns (currently Models::Sanitizable).
|
|
17
|
+
#
|
|
18
|
+
# Picks the HTML5 parser (Rails::HTML5::*, the default since Rails 7.1, so
|
|
19
|
+
# it matches the host app's own ActionView sanitize/strip_tags output) when
|
|
20
|
+
# the platform supports it, and otherwise falls back to HTML4 (libgumbo /
|
|
21
|
+
# HTML5 is unavailable on JRuby) — mirroring Rails core.
|
|
22
|
+
#
|
|
23
|
+
# The namespace decision and each sanitizer are built lazily on first use,
|
|
24
|
+
# so libgumbo / ActionView is never probed at file-load time, and the
|
|
25
|
+
# instances are reused (they are thread-safe for #sanitize) rather than
|
|
26
|
+
# re-allocated per attribute access.
|
|
27
|
+
#
|
|
28
|
+
# We reference Rails::HTML5 / Rails::HTML4 explicitly: the bare
|
|
29
|
+
# Rails::HTML::* aliases silently resolve to the HTML4 implementation.
|
|
30
|
+
module HtmlSanitizers
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def namespace
|
|
34
|
+
@namespace ||=
|
|
35
|
+
if defined?(Rails::HTML::Sanitizer) &&
|
|
36
|
+
Rails::HTML::Sanitizer.respond_to?(:html5_support?) &&
|
|
37
|
+
Rails::HTML::Sanitizer.html5_support?
|
|
38
|
+
Rails::HTML5
|
|
39
|
+
else
|
|
40
|
+
Rails::HTML4
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Removes every tag, keeping the inner text. The safe default and the
|
|
45
|
+
# only sanitizer appropriate for a destructive write (it cannot
|
|
46
|
+
# reintroduce markup).
|
|
47
|
+
def full
|
|
48
|
+
@full ||= namespace::FullSanitizer.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Rails' curated allow-list: keeps formatting tags (em / strong / a / p…),
|
|
52
|
+
# drops <script> / <iframe>, and neutralizes javascript: URLs.
|
|
53
|
+
def safe
|
|
54
|
+
@safe ||= namespace::SafeListSanitizer.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Strips only <a> tags, keeping their visible text and other markup.
|
|
58
|
+
def link
|
|
59
|
+
@link ||= namespace::LinkSanitizer.new
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -12,6 +12,7 @@ require "concerns_on_rails/support/column_guard"
|
|
|
12
12
|
require "concerns_on_rails/support/random_value"
|
|
13
13
|
require "concerns_on_rails/support/address_data"
|
|
14
14
|
require "concerns_on_rails/support/sequence_calculator"
|
|
15
|
+
require "concerns_on_rails/support/html_sanitizers"
|
|
15
16
|
|
|
16
17
|
# Model concerns
|
|
17
18
|
require "concerns_on_rails/models/sluggable"
|
|
@@ -29,6 +30,7 @@ require "concerns_on_rails/models/stateable"
|
|
|
29
30
|
require "concerns_on_rails/models/addressable"
|
|
30
31
|
require "concerns_on_rails/models/sequenceable"
|
|
31
32
|
require "concerns_on_rails/models/taggable"
|
|
33
|
+
require "concerns_on_rails/models/sanitizable"
|
|
32
34
|
|
|
33
35
|
# Controller concerns
|
|
34
36
|
require "concerns_on_rails/controllers/paginatable"
|
|
@@ -37,6 +39,7 @@ require "concerns_on_rails/controllers/sortable"
|
|
|
37
39
|
require "concerns_on_rails/controllers/respondable"
|
|
38
40
|
require "concerns_on_rails/controllers/error_handleable"
|
|
39
41
|
require "concerns_on_rails/controllers/includable"
|
|
42
|
+
require "concerns_on_rails/controllers/secure_headable"
|
|
40
43
|
|
|
41
44
|
# Backwards compatibility (top-level aliases for pre-1.6 module paths)
|
|
42
45
|
require "concerns_on_rails/legacy_aliases"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.12.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -75,6 +75,7 @@ files:
|
|
|
75
75
|
- lib/concerns_on_rails/controllers/includable.rb
|
|
76
76
|
- lib/concerns_on_rails/controllers/paginatable.rb
|
|
77
77
|
- lib/concerns_on_rails/controllers/respondable.rb
|
|
78
|
+
- lib/concerns_on_rails/controllers/secure_headable.rb
|
|
78
79
|
- lib/concerns_on_rails/controllers/sortable.rb
|
|
79
80
|
- lib/concerns_on_rails/legacy_aliases.rb
|
|
80
81
|
- lib/concerns_on_rails/models/activatable.rb
|
|
@@ -83,6 +84,7 @@ files:
|
|
|
83
84
|
- lib/concerns_on_rails/models/hashable.rb
|
|
84
85
|
- lib/concerns_on_rails/models/normalizable.rb
|
|
85
86
|
- lib/concerns_on_rails/models/publishable.rb
|
|
87
|
+
- lib/concerns_on_rails/models/sanitizable.rb
|
|
86
88
|
- lib/concerns_on_rails/models/schedulable.rb
|
|
87
89
|
- lib/concerns_on_rails/models/searchable.rb
|
|
88
90
|
- lib/concerns_on_rails/models/sequenceable.rb
|
|
@@ -94,6 +96,7 @@ files:
|
|
|
94
96
|
- lib/concerns_on_rails/models/tokenizable.rb
|
|
95
97
|
- lib/concerns_on_rails/support/address_data.rb
|
|
96
98
|
- lib/concerns_on_rails/support/column_guard.rb
|
|
99
|
+
- lib/concerns_on_rails/support/html_sanitizers.rb
|
|
97
100
|
- lib/concerns_on_rails/support/random_value.rb
|
|
98
101
|
- lib/concerns_on_rails/support/sequence_calculator.rb
|
|
99
102
|
- lib/concerns_on_rails/version.rb
|