concerns_on_rails 1.11.2 → 1.13.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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +165 -1
- data/lib/concerns_on_rails/controllers/localizable.rb +96 -0
- data/lib/concerns_on_rails/controllers/secure_headable.rb +105 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +3 -0
- data/lib/concerns_on_rails/models/maskable.rb +79 -0
- data/lib/concerns_on_rails/models/monetizable.rb +89 -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/support/masker.rb +60 -0
- data/lib/concerns_on_rails/support/money.rb +38 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +8 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b5bc2f3b03563d643ff2e9cffe13038cf90482499d88475ef49fea9d37ea5f9
|
|
4
|
+
data.tar.gz: d4b3228e473a838929c108d548674cb7b17738c62af1e1dee7454651f405e68c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f860c2f0c0a6d7570985c6a0f13c5b923ddb7aa12bcb08060b9dbbcd7ee6106182679d99ac3e7826835d8ccea708ef8bc82101aeb6c142fe20cbfbac77f636b
|
|
7
|
+
data.tar.gz: 865441dcf91d87c664809919a930ac2aedfa27fce106570a77e0dcf2331cc074313d0f63abecd21691b36c3af5861c72cc21b956ed70e11896ef411737287671
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.13.0 (2026-06-06)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Models::Maskable**: non-destructive display masking for sensitive attributes. `maskable :email, with: :email` adds a `masked_<field>` reader and never writes the column (the raw value stays in the DB). Presets `:email` / `:phone` / `:credit_card` / `:last4` / `:all`, a configurable `mask:` character, and a `Proc` escape hatch. Backed by `Support::Masker`; complements `Sanitizable`.
|
|
7
|
+
- **Models::Monetizable**: exact, float-free money handling over an integer subunit column. `monetizable :price_cents` derives `price` / `price=` / `formatted_price` (the `_cents` suffix is stripped, or name them with `as:`). Options `unit:` / `precision:` / `delimiter:` / `separator:` / `subunit_to_unit:`. Uses `BigDecimal` throughout; backed by `Support::Money`.
|
|
8
|
+
- **Controllers::Localizable**: per-request locale selection from `params` and/or the `Accept-Language` header via an `around_action` (`I18n.with_locale`). `localizable available: %i[en fr], default: :en`; options `param:` / `header:`. The resolved locale is always validated against `I18n.available_locales`, so it can never raise `I18n::InvalidLocale`.
|
|
9
|
+
|
|
10
|
+
### Notes
|
|
11
|
+
- All changes are additive and backward-compatible, with zero new runtime dependencies (BigDecimal and I18n already ship with the existing stack). `ConcernsOnRails::Maskable` / `ConcernsOnRails::Monetizable` are aliased to their `Models::*` modules; the controller concern stays namespace-only (`ConcernsOnRails::Controllers::Localizable`).
|
|
12
|
+
|
|
13
|
+
## 1.12.1 (2026-06-06)
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **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.
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
|
|
20
|
+
### Notes
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
3
24
|
## 1.11.2 (2026-06-06)
|
|
4
25
|
|
|
5
26
|
### Added
|
data/README.md
CHANGED
|
@@ -40,6 +40,9 @@ 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)
|
|
44
|
+
- [Maskable](#-maskable) — non-destructive display masking of sensitive fields
|
|
45
|
+
- [Monetizable](#-monetizable) — integer-cents money columns (BigDecimal)
|
|
43
46
|
- **Controller concerns**
|
|
44
47
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
45
48
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
@@ -47,6 +50,8 @@ Article.published.without_deleted.find("hello-world")
|
|
|
47
50
|
- [Respondable](#-respondable) — standardized JSON envelopes
|
|
48
51
|
- [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
|
|
49
52
|
- [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
|
|
53
|
+
- [SecureHeadable](#-secureheadable) — security response headers + native CSP DSL
|
|
54
|
+
- [Localizable](#-localizable) — per-request locale from params / Accept-Language
|
|
50
55
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
51
56
|
- [Development](#-development)
|
|
52
57
|
- [Contributing](#-contributing)
|
|
@@ -56,7 +61,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
56
61
|
|
|
57
62
|
## ✨ Why this gem?
|
|
58
63
|
|
|
59
|
-
- **
|
|
64
|
+
- **Eighteen model concerns + eight controller concerns**, all production-ready
|
|
60
65
|
- **One include, one macro** — no boilerplate, no glue code
|
|
61
66
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
62
67
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -827,6 +832,106 @@ Article.all_tags # => sorted unique tags in use
|
|
|
827
832
|
|
|
828
833
|
---
|
|
829
834
|
|
|
835
|
+
## 🧼 Sanitizable
|
|
836
|
+
|
|
837
|
+
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.
|
|
838
|
+
|
|
839
|
+
```ruby
|
|
840
|
+
class Article < ApplicationRecord
|
|
841
|
+
include ConcernsOnRails::Sanitizable
|
|
842
|
+
|
|
843
|
+
# DEFAULT (on: :read) — non-destructive. The column stays raw; a
|
|
844
|
+
# `sanitized_<field>` reader returns the cleaned value:
|
|
845
|
+
sanitizable :body, with: :safe_list # => article.sanitized_body
|
|
846
|
+
sanitizable :summary, with: :strip # => article.sanitized_summary
|
|
847
|
+
sanitizable :body, with: { tags: %w[b i a], attributes: %w[href] }
|
|
848
|
+
|
|
849
|
+
# EXPLICIT destructive opt-in — for plain-text-only columns only:
|
|
850
|
+
sanitizable :title, with: :strip, on: :write # overwrites in before_validation
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
article = Article.new(body: "<b>Hi</b><script>alert(1)</script>")
|
|
854
|
+
article.body # => "<b>Hi</b><script>alert(1)</script>" (raw, intact)
|
|
855
|
+
article.sanitized_body # => "<b>Hi</b>alert(1)" (script tag dropped)
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
**Presets** (`with:`)
|
|
859
|
+
|
|
860
|
+
| Preset | Behavior |
|
|
861
|
+
|--------------|------------------------------------------------------------------------|
|
|
862
|
+
| `:strip` | Remove all tags, keep inner text (the default). |
|
|
863
|
+
| `:safe_list` | Rails' allow-list: keep formatting tags, drop `<script>` / `<iframe>`. |
|
|
864
|
+
| `:no_links` | Strip only `<a>` tags, keep their text. |
|
|
865
|
+
| `:none` | No-op (declare the field / reader without transforming). |
|
|
866
|
+
| `Array` | Custom tag allow-list, e.g. `with: %w[b i a]`. |
|
|
867
|
+
| `Hash` | `{ tags: [...], attributes: [...] }` allow-list. |
|
|
868
|
+
| `Proc` | Used as-is (you own the non-String guard). |
|
|
869
|
+
|
|
870
|
+
**Notes**
|
|
871
|
+
- `on: :read` (default) is **non-destructive**: it adds a `sanitized_<field>` reader and leaves the stored column untouched.
|
|
872
|
+
- `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.
|
|
873
|
+
- For full user-authored rich text, prefer [Action Text](https://guides.rubyonrails.org/action_text_overview.html).
|
|
874
|
+
|
|
875
|
+
---
|
|
876
|
+
|
|
877
|
+
## 🙈 Maskable
|
|
878
|
+
|
|
879
|
+
Non-destructive display masking for sensitive attributes. Each declaration adds a `masked_<field>` reader and **never writes the column** — the raw value stays in the database (masking is a presentation concern). Dependency-free.
|
|
880
|
+
|
|
881
|
+
```ruby
|
|
882
|
+
class User < ApplicationRecord
|
|
883
|
+
include ConcernsOnRails::Maskable
|
|
884
|
+
|
|
885
|
+
maskable :email, with: :email # => user.masked_email "j****@example.com"
|
|
886
|
+
maskable :card, with: :credit_card # => user.masked_card "**** **** **** 4242"
|
|
887
|
+
maskable :ssn, with: :last4, mask: "•"
|
|
888
|
+
maskable :token, with: ->(v) { "#{v.to_s[0, 3]}…" }
|
|
889
|
+
end
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
**Presets** (`with:`)
|
|
893
|
+
|
|
894
|
+
| Preset | Result |
|
|
895
|
+
|----------------|---------------------------------------------|
|
|
896
|
+
| `:email` | `j****@example.com` (first char + domain) |
|
|
897
|
+
| `:phone` | `***-2671` (last 4 digits) |
|
|
898
|
+
| `:credit_card` | `**** **** **** 4242` (last 4 digits) |
|
|
899
|
+
| `:last4` | keep the last 4 characters |
|
|
900
|
+
| `:all` | mask every character (the default) |
|
|
901
|
+
| `Proc` | used as-is (you own the non-String guard) |
|
|
902
|
+
|
|
903
|
+
`mask:` sets the mask character (default `*`). Nil and non-string values pass through untouched. To strip dangerous HTML instead, see [Sanitizable](#-sanitizable).
|
|
904
|
+
|
|
905
|
+
---
|
|
906
|
+
|
|
907
|
+
## 💰 Monetizable
|
|
908
|
+
|
|
909
|
+
Money handling for an integer "subunit" column (e.g. cents) — exact and float-free via `BigDecimal`. `monetizable :price_cents` derives three methods (the `_cents` suffix is stripped):
|
|
910
|
+
|
|
911
|
+
```ruby
|
|
912
|
+
class Product < ApplicationRecord
|
|
913
|
+
include ConcernsOnRails::Monetizable
|
|
914
|
+
|
|
915
|
+
monetizable :price_cents # => price / price= / formatted_price
|
|
916
|
+
monetizable :shipping_cents, as: :shipping
|
|
917
|
+
monetizable :total_cents, unit: "€", delimiter: ".", separator: ","
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
product.price = 19.99 # stores price_cents = 1999 (rounded to whole cents)
|
|
921
|
+
product.price # => BigDecimal 19.99
|
|
922
|
+
product.formatted_price # => "$19.99"
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
| Method | Returns |
|
|
926
|
+
|-------------------|-----------------------------------------------|
|
|
927
|
+
| `price` | the amount as a `BigDecimal` (cents ÷ 100) |
|
|
928
|
+
| `price=` | assign in major units; rounded to whole cents |
|
|
929
|
+
| `formatted_price` | a display string (`"$1,234.56"`) |
|
|
930
|
+
|
|
931
|
+
**Options**: `as:` (explicit method name — required when the column does not end in `_cents`), `unit:` (`"$"`), `precision:` (`2`), `delimiter:` (`","`), `separator:` (`"."`), `subunit_to_unit:` (`100`). `nil` stays `nil` across all three methods.
|
|
932
|
+
|
|
933
|
+
---
|
|
934
|
+
|
|
830
935
|
# 🎮 Controller Concerns
|
|
831
936
|
|
|
832
937
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1050,6 +1155,65 @@ GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=
|
|
|
1050
1155
|
|
|
1051
1156
|
---
|
|
1052
1157
|
|
|
1158
|
+
## 🛡️ SecureHeadable
|
|
1159
|
+
|
|
1160
|
+
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.
|
|
1161
|
+
|
|
1162
|
+
```ruby
|
|
1163
|
+
class ApplicationController < ActionController::Base
|
|
1164
|
+
include ConcernsOnRails::Controllers::SecureHeadable
|
|
1165
|
+
|
|
1166
|
+
# Preset headers, plus any custom "Header-Name" => "value" pairs:
|
|
1167
|
+
secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
|
|
1168
|
+
secure_headers "Permissions-Policy" => "geolocation=()"
|
|
1169
|
+
|
|
1170
|
+
# Delegates to Rails' native CSP DSL — roll out report-only FIRST:
|
|
1171
|
+
content_security_policy_for(report_only: true) do |policy|
|
|
1172
|
+
policy.default_src :self
|
|
1173
|
+
policy.script_src :self
|
|
1174
|
+
policy.object_src :none
|
|
1175
|
+
end
|
|
1176
|
+
end
|
|
1177
|
+
```
|
|
1178
|
+
|
|
1179
|
+
**Header presets** (`secure_headers`)
|
|
1180
|
+
|
|
1181
|
+
| Preset | Header |
|
|
1182
|
+
|-----------------------|--------------------------------------------------------|
|
|
1183
|
+
| `:nosniff` | `X-Content-Type-Options: nosniff` |
|
|
1184
|
+
| `:sameorigin_frame` | `X-Frame-Options: SAMEORIGIN` |
|
|
1185
|
+
| `:deny_frame` | `X-Frame-Options: DENY` |
|
|
1186
|
+
| `:no_referrer_leak` | `Referrer-Policy: strict-origin-when-cross-origin` |
|
|
1187
|
+
| `:no_cross_domain` | `X-Permitted-Cross-Domain-Policies: none` |
|
|
1188
|
+
| `:disable_legacy_xss` | `X-XSS-Protection: 0` (the only correct modern value) |
|
|
1189
|
+
|
|
1190
|
+
**Notes**
|
|
1191
|
+
- Headers are applied in an `after_action`, so they reinforce Rails' middleware defaults; later `secure_headers` declarations win on a colliding name.
|
|
1192
|
+
- `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.
|
|
1193
|
+
- CSP nonce generation (`content_security_policy_nonce_generator`) is app-wide initializer config and intentionally stays out of the concern.
|
|
1194
|
+
- These headers mitigate clickjacking / MIME-sniffing and (via CSP) XSS as **defense-in-depth** — output escaping remains the primary defense.
|
|
1195
|
+
|
|
1196
|
+
---
|
|
1197
|
+
|
|
1198
|
+
## 🌐 Localizable
|
|
1199
|
+
|
|
1200
|
+
Per-request locale selection from the request params and/or the `Accept-Language` header, wrapped in an `around_action` so `I18n.locale` is set for the action and restored afterwards. Dependency-free.
|
|
1201
|
+
|
|
1202
|
+
```ruby
|
|
1203
|
+
class ApplicationController < ActionController::Base
|
|
1204
|
+
include ConcernsOnRails::Controllers::Localizable
|
|
1205
|
+
|
|
1206
|
+
localizable available: %i[en fr de], default: :en
|
|
1207
|
+
# localizable param: :lang, header: false # params[:lang] only
|
|
1208
|
+
end
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
Resolution order: `params[param]` → first match in `Accept-Language` → `default` → `I18n.default_locale`. The chosen locale is always validated against `I18n.available_locales`, so a stray param or a mismatched `available:` list can never raise `I18n::InvalidLocale`.
|
|
1212
|
+
|
|
1213
|
+
**Options**: `available:` (allow-list for matching; defaults to `I18n.available_locales`), `default:`, `param:` (default `:locale`), `header:` (default `true`).
|
|
1214
|
+
|
|
1215
|
+
---
|
|
1216
|
+
|
|
1053
1217
|
## 🗂️ Module paths & namespacing
|
|
1054
1218
|
|
|
1055
1219
|
Every concern is available under two paths:
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Controllers
|
|
5
|
+
# Per-request locale selection from the request params and/or the
|
|
6
|
+
# `Accept-Language` header, wrapped in an `around_action` so `I18n.locale`
|
|
7
|
+
# is set for the action and restored afterwards.
|
|
8
|
+
#
|
|
9
|
+
# class ApplicationController < ActionController::Base
|
|
10
|
+
# include ConcernsOnRails::Controllers::Localizable
|
|
11
|
+
#
|
|
12
|
+
# localizable available: %i[en fr de], default: :en
|
|
13
|
+
# # localizable param: :lang, header: false # params[:lang] only
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Resolution order: `params[param]` → first match in `Accept-Language` →
|
|
17
|
+
# `default` → `I18n.default_locale`. The chosen locale is always validated
|
|
18
|
+
# against `I18n.available_locales` before use, so a stray param or a
|
|
19
|
+
# mismatched `available:` list can never raise `I18n::InvalidLocale`.
|
|
20
|
+
#
|
|
21
|
+
# Options: `available:` (allow-list for param/header matching; defaults to
|
|
22
|
+
# `I18n.available_locales`), `default:`, `param:` (default `:locale`),
|
|
23
|
+
# `header:` (default `true`).
|
|
24
|
+
module Localizable
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
|
|
27
|
+
included do
|
|
28
|
+
class_attribute :localizable_options, instance_accessor: false, default: {}
|
|
29
|
+
around_action :switch_locale
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
def localizable(available: nil, default: nil, param: :locale, header: true)
|
|
34
|
+
self.localizable_options = {
|
|
35
|
+
available: available&.map(&:to_sym),
|
|
36
|
+
default: default&.to_sym,
|
|
37
|
+
param: param&.to_sym,
|
|
38
|
+
header: header
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Public so subclasses can override; runs the action under the resolved locale.
|
|
44
|
+
def switch_locale(&)
|
|
45
|
+
I18n.with_locale(resolved_locale, &)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The locale chosen for this request — always one I18n can switch to.
|
|
49
|
+
def resolved_locale
|
|
50
|
+
opts = self.class.localizable_options
|
|
51
|
+
allowed = opts[:available].presence || I18n.available_locales
|
|
52
|
+
candidate = locale_from_param(opts, allowed) || locale_from_header(opts, allowed) || opts[:default]
|
|
53
|
+
|
|
54
|
+
candidate && I18n.available_locales.include?(candidate.to_sym) ? candidate.to_sym : I18n.default_locale
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def locale_from_param(opts, allowed)
|
|
60
|
+
return nil unless opts[:param] && respond_to?(:params) && params
|
|
61
|
+
|
|
62
|
+
match_locale(params[opts[:param]], allowed)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def locale_from_header(opts, allowed)
|
|
66
|
+
return nil unless opts[:header]
|
|
67
|
+
|
|
68
|
+
header = accept_language_header
|
|
69
|
+
header.blank? ? nil : parse_accept_language(header, allowed)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def accept_language_header
|
|
73
|
+
return nil unless respond_to?(:request)
|
|
74
|
+
|
|
75
|
+
req = request
|
|
76
|
+
req.respond_to?(:headers) ? req.headers["Accept-Language"] : nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_accept_language(header, allowed)
|
|
80
|
+
header.split(",").each do |part|
|
|
81
|
+
lang = part.split(";").first.to_s.strip.split("-").first
|
|
82
|
+
match = match_locale(lang, allowed)
|
|
83
|
+
return match if match
|
|
84
|
+
end
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def match_locale(candidate, allowed)
|
|
89
|
+
return nil if candidate.blank?
|
|
90
|
+
|
|
91
|
+
wanted = candidate.to_s.downcase
|
|
92
|
+
allowed.find { |loc| loc.to_s.downcase == wanted }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -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,79 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/masker"
|
|
3
|
+
|
|
4
|
+
module ConcernsOnRails
|
|
5
|
+
module Models
|
|
6
|
+
# Non-destructive display masking for sensitive string attributes.
|
|
7
|
+
#
|
|
8
|
+
# Masking is ALWAYS read-only: each declaration adds a `masked_<field>`
|
|
9
|
+
# reader and never writes the stored column (the raw value stays in the DB,
|
|
10
|
+
# because masking is a presentation concern). For stripping dangerous HTML
|
|
11
|
+
# see Models::Sanitizable.
|
|
12
|
+
#
|
|
13
|
+
# class User < ApplicationRecord
|
|
14
|
+
# include ConcernsOnRails::Models::Maskable
|
|
15
|
+
#
|
|
16
|
+
# maskable :email, with: :email # => user.masked_email "j****@example.com"
|
|
17
|
+
# maskable :card, with: :credit_card # => user.masked_card "**** **** **** 4242"
|
|
18
|
+
# maskable :ssn, with: :last4, mask: "•"
|
|
19
|
+
# maskable :token, with: ->(v) { "#{v.to_s[0, 3]}…" }
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Presets (the `with:` argument):
|
|
23
|
+
# :email — mask the local part, keep first char + domain
|
|
24
|
+
# :phone — keep the last 4 digits ("***-2671")
|
|
25
|
+
# :credit_card — keep the last 4 digits ("**** **** **** 4242")
|
|
26
|
+
# :last4 — keep the last 4 characters
|
|
27
|
+
# :all — mask every character (the default)
|
|
28
|
+
# Proc — used as-is (the caller owns the non-String guard)
|
|
29
|
+
#
|
|
30
|
+
# `mask:` sets the mask character (default "*") for the preset forms.
|
|
31
|
+
module Maskable
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
PRESETS = %i[email phone credit_card last4 all].freeze
|
|
35
|
+
|
|
36
|
+
included do
|
|
37
|
+
class_attribute :maskable_rules, instance_accessor: false, default: {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class_methods do
|
|
41
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
42
|
+
|
|
43
|
+
def maskable(*fields, with: :all, mask: "*")
|
|
44
|
+
raise ArgumentError, "ConcernsOnRails::Models::Maskable: at least one field is required" if fields.empty?
|
|
45
|
+
|
|
46
|
+
masker = resolve_masker(with, mask)
|
|
47
|
+
ensure_columns!("ConcernsOnRails::Models::Maskable", fields)
|
|
48
|
+
|
|
49
|
+
fields.each do |field|
|
|
50
|
+
key = field.to_sym
|
|
51
|
+
self.maskable_rules = maskable_rules.merge(key => masker)
|
|
52
|
+
define_method("masked_#{field}") { masker.call(self[key]) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class_methods do
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def resolve_masker(with, mask)
|
|
61
|
+
case with
|
|
62
|
+
when Symbol
|
|
63
|
+
unless PRESETS.include?(with)
|
|
64
|
+
raise ArgumentError,
|
|
65
|
+
"ConcernsOnRails::Models::Maskable: unknown preset '#{with}'. " \
|
|
66
|
+
"Valid presets: #{PRESETS.join(', ')}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
->(v) { ConcernsOnRails::Support::Masker.public_send(with, v, mask: mask) }
|
|
70
|
+
when Proc then with
|
|
71
|
+
else
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"ConcernsOnRails::Models::Maskable: :with must be a preset symbol or a Proc/lambda, got #{with.class}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/money"
|
|
3
|
+
|
|
4
|
+
module ConcernsOnRails
|
|
5
|
+
module Models
|
|
6
|
+
# Money handling for an integer "subunit" column (e.g. cents) — exact,
|
|
7
|
+
# float-free, via BigDecimal.
|
|
8
|
+
#
|
|
9
|
+
# Declaring `monetizable :price_cents` adds three methods derived from the
|
|
10
|
+
# column name (the `_cents` suffix is stripped):
|
|
11
|
+
# * `price` — the amount as a BigDecimal (cents / 100)
|
|
12
|
+
# * `price=` — assign in major units; rounded to whole cents
|
|
13
|
+
# * `formatted_price` — a display string ("$1,234.56")
|
|
14
|
+
#
|
|
15
|
+
# class Product < ApplicationRecord
|
|
16
|
+
# include ConcernsOnRails::Models::Monetizable
|
|
17
|
+
#
|
|
18
|
+
# monetizable :price_cents # => price / price= / formatted_price
|
|
19
|
+
# monetizable :shipping_cents, as: :shipping
|
|
20
|
+
# monetizable :total_cents, unit: "€", separator: ",", delimiter: "."
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# product.price = 19.99 # stores price_cents = 1999
|
|
24
|
+
# product.price # => 0.1999e2 (BigDecimal 19.99)
|
|
25
|
+
# product.formatted_price # => "$19.99"
|
|
26
|
+
#
|
|
27
|
+
# Options: `as:` (explicit method name — required when the column does not
|
|
28
|
+
# end in `_cents`), `unit:` ("$"), `precision:` (2), `delimiter:` (","),
|
|
29
|
+
# `separator:` ("."), `subunit_to_unit:` (100).
|
|
30
|
+
module Monetizable
|
|
31
|
+
extend ActiveSupport::Concern
|
|
32
|
+
|
|
33
|
+
included do
|
|
34
|
+
class_attribute :monetizable_rules, instance_accessor: false, default: {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class_methods do
|
|
38
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
39
|
+
|
|
40
|
+
def monetizable(*fields, as: nil, unit: "$", precision: 2, delimiter: ",", separator: ".", subunit_to_unit: 100)
|
|
41
|
+
raise ArgumentError, "ConcernsOnRails::Models::Monetizable: at least one field is required" if fields.empty?
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :as cannot be combined with multiple fields" if as && fields.size > 1
|
|
44
|
+
|
|
45
|
+
ensure_columns!("ConcernsOnRails::Models::Monetizable", fields)
|
|
46
|
+
config = { unit: unit, precision: precision, delimiter: delimiter, separator: separator, subunit_to_unit: subunit_to_unit }
|
|
47
|
+
fields.each { |cents_field| define_money_accessors(cents_field.to_sym, as, config) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class_methods do # rubocop:disable Metrics/BlockLength
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def define_money_accessors(cents_field, as, config)
|
|
55
|
+
name = money_name(cents_field, as)
|
|
56
|
+
subunit = config[:subunit_to_unit]
|
|
57
|
+
self.monetizable_rules = monetizable_rules.merge(cents_field => name)
|
|
58
|
+
|
|
59
|
+
define_method(name) do
|
|
60
|
+
cents = self[cents_field]
|
|
61
|
+
cents.nil? ? nil : BigDecimal(cents.to_s) / subunit
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
define_method("#{name}=") do |amount|
|
|
65
|
+
self[cents_field] = amount.nil? ? nil : (BigDecimal(amount.to_s) * subunit).round
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
define_method("formatted_#{name}") do
|
|
69
|
+
cents = self[cents_field]
|
|
70
|
+
cents.nil? ? nil : ConcernsOnRails::Support::Money.format(cents, config)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def money_name(cents_field, as)
|
|
75
|
+
return as.to_sym if as
|
|
76
|
+
|
|
77
|
+
str = cents_field.to_s
|
|
78
|
+
unless str.end_with?("_cents")
|
|
79
|
+
raise ArgumentError,
|
|
80
|
+
"ConcernsOnRails::Models::Monetizable: cannot derive a money method name from '#{cents_field}' " \
|
|
81
|
+
"(it does not end in '_cents'); pass `as:` to name it explicitly"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
str.delete_suffix("_cents").to_sym
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
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
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Support
|
|
5
|
+
# Display-only value-masking helpers shared by Models::Maskable.
|
|
6
|
+
#
|
|
7
|
+
# Every method is string-safe: a non-String argument is returned untouched,
|
|
8
|
+
# exactly like the Normalizable / Sanitizable preset lambdas. Masking is for
|
|
9
|
+
# presentation only — callers keep the original value in the database.
|
|
10
|
+
module Masker
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
DEFAULT_MASK = "*".freeze
|
|
14
|
+
|
|
15
|
+
# Replace every character with the mask character.
|
|
16
|
+
def all(value, mask: DEFAULT_MASK)
|
|
17
|
+
value.is_a?(String) ? mask * value.length : value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Keep only the last four characters visible.
|
|
21
|
+
def last4(value, mask: DEFAULT_MASK)
|
|
22
|
+
return value unless value.is_a?(String)
|
|
23
|
+
|
|
24
|
+
value.length <= 4 ? mask * value.length : (mask * (value.length - 4)) + value[-4..]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Mask the local part of an email, keeping the first character + domain:
|
|
28
|
+
# "john.doe@example.com" => "j*******@example.com"
|
|
29
|
+
def email(value, mask: DEFAULT_MASK)
|
|
30
|
+
return value unless value.is_a?(String)
|
|
31
|
+
|
|
32
|
+
local, at, domain = value.partition("@")
|
|
33
|
+
return value if at.empty? # not an email-shaped string; leave it alone
|
|
34
|
+
|
|
35
|
+
masked_local = local.length <= 1 ? mask : local[0] + (mask * (local.length - 1))
|
|
36
|
+
"#{masked_local}@#{domain}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Keep the last four digits of a phone number visible: "***-2671".
|
|
40
|
+
def phone(value, mask: DEFAULT_MASK)
|
|
41
|
+
return value unless value.is_a?(String)
|
|
42
|
+
|
|
43
|
+
digits = value.gsub(/\D/, "")
|
|
44
|
+
return value if digits.empty?
|
|
45
|
+
|
|
46
|
+
"#{mask * 3}-#{digits.length <= 4 ? digits : digits[-4..]}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Keep the last four digits of a card number: "**** **** **** 4242".
|
|
50
|
+
def credit_card(value, mask: DEFAULT_MASK)
|
|
51
|
+
return value unless value.is_a?(String)
|
|
52
|
+
|
|
53
|
+
digits = value.gsub(/\D/, "")
|
|
54
|
+
return all(value, mask: mask) if digits.length <= 4
|
|
55
|
+
|
|
56
|
+
"#{mask * 4} #{mask * 4} #{mask * 4} #{digits[-4..]}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "bigdecimal"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Support
|
|
5
|
+
# Formats an integer subunit amount (e.g. cents) as a human-readable money
|
|
6
|
+
# string. Pure and stateless; used by Models::Monetizable. Uses BigDecimal
|
|
7
|
+
# throughout so there is no binary-float rounding drift.
|
|
8
|
+
module Money
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# format(199999) => "$1,999.99"
|
|
12
|
+
# format(-500, unit: "£") => "-£5.00"
|
|
13
|
+
# format(1234, unit: "¥", precision: 0, subunit_to_unit: 1) => "¥1,234"
|
|
14
|
+
def format(cents, options = {})
|
|
15
|
+
unit = options.fetch(:unit, "$")
|
|
16
|
+
precision = options.fetch(:precision, 2)
|
|
17
|
+
delimiter = options.fetch(:delimiter, ",")
|
|
18
|
+
separator = options.fetch(:separator, ".")
|
|
19
|
+
subunit = options.fetch(:subunit_to_unit, 100)
|
|
20
|
+
|
|
21
|
+
decimal = BigDecimal(cents.to_s) / subunit
|
|
22
|
+
# BigDecimal#round returns an Integer for precision <= 0, so re-wrap it
|
|
23
|
+
# in a BigDecimal before #to_s("F") (Integer#to_s would read "F" as a radix).
|
|
24
|
+
rounded = BigDecimal(decimal.abs.round(precision).to_s)
|
|
25
|
+
whole, _, frac = rounded.to_s("F").partition(".")
|
|
26
|
+
whole = delimit(whole, delimiter)
|
|
27
|
+
number = precision.positive? ? "#{whole}#{separator}#{frac.ljust(precision, '0')[0, precision]}" : whole
|
|
28
|
+
|
|
29
|
+
"#{'-' if decimal.negative?}#{unit}#{number}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Insert the thousands delimiter into a non-negative integer string.
|
|
33
|
+
def delimit(integer_string, delimiter)
|
|
34
|
+
integer_string.reverse.gsub(/(\d{3})(?=\d)/, "\\1#{delimiter}").reverse
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -12,6 +12,9 @@ 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"
|
|
16
|
+
require "concerns_on_rails/support/masker"
|
|
17
|
+
require "concerns_on_rails/support/money"
|
|
15
18
|
|
|
16
19
|
# Model concerns
|
|
17
20
|
require "concerns_on_rails/models/sluggable"
|
|
@@ -29,6 +32,9 @@ require "concerns_on_rails/models/stateable"
|
|
|
29
32
|
require "concerns_on_rails/models/addressable"
|
|
30
33
|
require "concerns_on_rails/models/sequenceable"
|
|
31
34
|
require "concerns_on_rails/models/taggable"
|
|
35
|
+
require "concerns_on_rails/models/sanitizable"
|
|
36
|
+
require "concerns_on_rails/models/maskable"
|
|
37
|
+
require "concerns_on_rails/models/monetizable"
|
|
32
38
|
|
|
33
39
|
# Controller concerns
|
|
34
40
|
require "concerns_on_rails/controllers/paginatable"
|
|
@@ -37,6 +43,8 @@ require "concerns_on_rails/controllers/sortable"
|
|
|
37
43
|
require "concerns_on_rails/controllers/respondable"
|
|
38
44
|
require "concerns_on_rails/controllers/error_handleable"
|
|
39
45
|
require "concerns_on_rails/controllers/includable"
|
|
46
|
+
require "concerns_on_rails/controllers/secure_headable"
|
|
47
|
+
require "concerns_on_rails/controllers/localizable"
|
|
40
48
|
|
|
41
49
|
# Backwards compatibility (top-level aliases for pre-1.6 module paths)
|
|
42
50
|
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.13.0
|
|
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
|
|
@@ -73,16 +73,21 @@ files:
|
|
|
73
73
|
- lib/concerns_on_rails/controllers/error_handleable.rb
|
|
74
74
|
- lib/concerns_on_rails/controllers/filterable.rb
|
|
75
75
|
- lib/concerns_on_rails/controllers/includable.rb
|
|
76
|
+
- lib/concerns_on_rails/controllers/localizable.rb
|
|
76
77
|
- lib/concerns_on_rails/controllers/paginatable.rb
|
|
77
78
|
- lib/concerns_on_rails/controllers/respondable.rb
|
|
79
|
+
- lib/concerns_on_rails/controllers/secure_headable.rb
|
|
78
80
|
- lib/concerns_on_rails/controllers/sortable.rb
|
|
79
81
|
- lib/concerns_on_rails/legacy_aliases.rb
|
|
80
82
|
- lib/concerns_on_rails/models/activatable.rb
|
|
81
83
|
- lib/concerns_on_rails/models/addressable.rb
|
|
82
84
|
- lib/concerns_on_rails/models/expirable.rb
|
|
83
85
|
- lib/concerns_on_rails/models/hashable.rb
|
|
86
|
+
- lib/concerns_on_rails/models/maskable.rb
|
|
87
|
+
- lib/concerns_on_rails/models/monetizable.rb
|
|
84
88
|
- lib/concerns_on_rails/models/normalizable.rb
|
|
85
89
|
- lib/concerns_on_rails/models/publishable.rb
|
|
90
|
+
- lib/concerns_on_rails/models/sanitizable.rb
|
|
86
91
|
- lib/concerns_on_rails/models/schedulable.rb
|
|
87
92
|
- lib/concerns_on_rails/models/searchable.rb
|
|
88
93
|
- lib/concerns_on_rails/models/sequenceable.rb
|
|
@@ -94,6 +99,9 @@ files:
|
|
|
94
99
|
- lib/concerns_on_rails/models/tokenizable.rb
|
|
95
100
|
- lib/concerns_on_rails/support/address_data.rb
|
|
96
101
|
- lib/concerns_on_rails/support/column_guard.rb
|
|
102
|
+
- lib/concerns_on_rails/support/html_sanitizers.rb
|
|
103
|
+
- lib/concerns_on_rails/support/masker.rb
|
|
104
|
+
- lib/concerns_on_rails/support/money.rb
|
|
97
105
|
- lib/concerns_on_rails/support/random_value.rb
|
|
98
106
|
- lib/concerns_on_rails/support/sequence_calculator.rb
|
|
99
107
|
- lib/concerns_on_rails/version.rb
|