maquina-generators 0.6.0 → 0.7.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/README.md +31 -0
- data/lib/generators/maquina/clave/clave_generator.rb +48 -0
- data/lib/generators/maquina/clave/templates/app/controllers/registration/verifications_controller.rb.tt +10 -1
- data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt +10 -1
- data/lib/generators/maquina/clave/templates/app/models/email_verification.rb.tt +18 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.en.yml +2 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.es.yml +2 -0
- data/lib/generators/maquina/csp_reports/USAGE +18 -0
- data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +231 -0
- data/lib/generators/maquina/csp_reports/templates/app/controllers/csp_reports_controller.rb +24 -0
- data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation.rb +89 -0
- data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation_report.rb +30 -0
- data/lib/generators/maquina/csp_reports/templates/app/views/backstage/security/_csp_violations.html.erb +42 -0
- data/lib/generators/maquina/csp_reports/templates/db/create_csp_violations.rb.tt +23 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/clave_generator_test.rb +74 -0
- data/test/generators/maquina/csp_reports_generator_test.rb +151 -0
- data/test/tmp/.gitignore +5 -0
- data/test/tmp/.rubocop.yml +11 -0
- data/test/tmp/.standard.yml +1 -0
- data/test/tmp/Gemfile +13 -1
- data/test/tmp/Procfile.dev +3 -0
- data/test/tmp/README.md +89 -0
- data/test/tmp/app/controllers/home_controller.rb +6 -0
- data/test/tmp/app/javascript/application.js +1 -0
- data/test/tmp/app/views/home/index.html.erb +204 -0
- data/test/tmp/app/views/layouts/application.html.erb +10 -0
- data/test/tmp/config/application.rb +15 -0
- data/test/tmp/config/database.yml +90 -0
- data/test/tmp/config/database.yml.example +90 -0
- data/test/tmp/config/environments/development.rb +8 -0
- data/test/tmp/config/environments/production.rb +6 -0
- data/test/tmp/config/initializers/generators.rb +4 -0
- data/test/tmp/config/routes.rb +1 -2
- metadata +27 -14
- data/test/tmp/app/controllers/application_controller.rb +0 -2
- data/test/tmp/app/controllers/registrations_controller.rb +0 -37
- data/test/tmp/app/models/account.rb +0 -5
- data/test/tmp/app/models/current.rb +0 -5
- data/test/tmp/app/models/user.rb +0 -12
- data/test/tmp/app/views/registrations/new.html.erb +0 -72
- data/test/tmp/app/views/sessions/new.html.erb +0 -46
- data/test/tmp/config/locales/registration.en.yml +0 -44
- data/test/tmp/config/locales/registration.es.yml +0 -44
- data/test/tmp/db/migrate/20260927024163_create_accounts.rb +0 -9
- data/test/tmp/db/migrate/20260927024164_add_account_fields_to_users.rb +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a31ee05a1ed941cc88119c5721629c3c5089081dde3e7833d1768cb9598b202
|
|
4
|
+
data.tar.gz: 5bc0462a7009085c8a752791090347501a59886cab23eb74ba7bdd5ff0ff2f08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85c73f484e3ed4e09e28ec2bebf42ca13ea1dd84693b9e7abbffa0746f14aa69930d95607c0c592085ad6acd88ba2a5d81afd90e602c8265cf9122e05d86a3ef
|
|
7
|
+
data.tar.gz: bf4cec22c128729f8425b71d345a6ef166e6c9f4d18172860ef1eca3bbb894997397ce227297168f54218d6d26c87dc707dbedbd7b268594620543d22f1b6437
|
data/README.md
CHANGED
|
@@ -19,6 +19,8 @@ A collection of Rails generators from the Maquina umbrella. Each generator produ
|
|
|
19
19
|
- **Migrations:** 4 migrations (accounts, users, sessions, email_verifications)
|
|
20
20
|
- **Test helper:** `sign_in_as(user)` and `sign_out` for integration tests
|
|
21
21
|
|
|
22
|
+
Sign-in codes are encrypted at rest with Active Record encryption and compared in constant time. The generator adds the encryption keys to `config/credentials.yml.enc` when they are missing; without a master key, run `bin/rails db:encryption:init`.
|
|
23
|
+
|
|
22
24
|
#### Installation
|
|
23
25
|
|
|
24
26
|
Add to your Gemfile:
|
|
@@ -55,6 +57,7 @@ All generated code lives in your app -- edit it directly:
|
|
|
55
57
|
- **Session duration:** Edit `authentication.rb` (default: 30 days)
|
|
56
58
|
- **Code expiration:** Edit controllers (default: 15 minutes)
|
|
57
59
|
- **Cooldown between codes:** Edit `EmailVerification::COOLDOWN_MINUTES` (default: 15)
|
|
60
|
+
- **Wrong guesses before a code locks:** Edit `EmailVerification::MAX_ATTEMPTS` (default: 5)
|
|
58
61
|
- **Colors/styling:** Edit view templates (default: indigo)
|
|
59
62
|
- **Email sender:** Edit `app/mailers/verification_mailer.rb`
|
|
60
63
|
- **Translations:** Edit `config/locales/clave.*.yml`
|
|
@@ -322,6 +325,34 @@ Development's `:memory_store` keeps bans inside the server process, so they rese
|
|
|
322
325
|
|
|
323
326
|
---
|
|
324
327
|
|
|
328
|
+
### CSP Reports -- Content Security Policy Violation Endpoint
|
|
329
|
+
|
|
330
|
+
**CSP Reports** gives the browser somewhere to send Content Security Policy violations and shows them on the Security page. It needs `maquina:security`, whose `security` database stores them.
|
|
331
|
+
|
|
332
|
+
#### What it generates
|
|
333
|
+
|
|
334
|
+
- **Controller:** `CspReportsController` at `POST /csp-reports` -- an API controller with no session or CSRF that accepts both the legacy `report-uri` body and the Reporting API body, capped at 64 KB. Reported URLs lose their query and fragment, and browser-extension violations are dropped
|
|
335
|
+
- **Models:** `Security::CspViolation` (30-day retention) and `Security::CspViolationReport` (the page's figures)
|
|
336
|
+
- **Database:** a migration in `db/security_migrate` and the `csp_violations` table in `db/security_schema.rb`
|
|
337
|
+
- **Retention:** a daily `purge_csp_violations` task in `config/recurring.yml`
|
|
338
|
+
- **Security page:** a section listing the last 7 days of violations, most frequent first
|
|
339
|
+
- **Policy:** points `report_uri` at the endpoint in `config/initializers/content_security_policy.rb` (no `report-to`, which switches Chrome to the batched Reporting API) and gives anonymous requests a nonce -- Rails' session-id nonce is empty without a session
|
|
340
|
+
- **Rack::Attack:** takes the endpoint out of the general throttle and caps it at 100 reports per 5 minutes per IP
|
|
341
|
+
|
|
342
|
+
Every step is idempotent. Anything it cannot place safely is listed after the install instead of being forced.
|
|
343
|
+
|
|
344
|
+
#### Usage
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
rails g maquina:csp_reports
|
|
348
|
+
rails g maquina:csp_reports --path /security/csp-reports # Different endpoint path
|
|
349
|
+
bin/rails db:migrate
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Watch the Security page while the policy is report-only, then set `content_security_policy_report_only = false` to enforce it.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
325
356
|
### App -- Full Application Setup (Orchestrator)
|
|
326
357
|
|
|
327
358
|
**App** is a meta-generator that sets up a complete Rails application in one command. Run it after `rails new myapp --css tailwind`.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "rails/generators"
|
|
2
2
|
require "rails/generators/migration"
|
|
3
|
+
require "active_support/encrypted_configuration"
|
|
3
4
|
|
|
4
5
|
module Maquina
|
|
5
6
|
module Generators
|
|
@@ -42,6 +43,23 @@ module Maquina
|
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
# 2. Controllers
|
|
46
|
+
# EmailVerification encrypts its code, which needs Active Record
|
|
47
|
+
# encryption keys. They go into the app's credentials when missing; with
|
|
48
|
+
# no master key to write them, the post-install message says what to run.
|
|
49
|
+
def configure_encryption_keys
|
|
50
|
+
credentials = app_credentials
|
|
51
|
+
if credentials.nil?
|
|
52
|
+
@encryption_pending = true
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
content = credentials.read
|
|
57
|
+
return if content.match?(/^active_record_encryption:/)
|
|
58
|
+
|
|
59
|
+
credentials.write(content.rstrip + (content.strip.empty? ? "" : "\n\n") + encryption_keys_yaml)
|
|
60
|
+
@encryption_written = true
|
|
61
|
+
end
|
|
62
|
+
|
|
45
63
|
def create_controllers
|
|
46
64
|
template "app/controllers/concerns/authentication.rb.tt",
|
|
47
65
|
"app/controllers/concerns/authentication.rb"
|
|
@@ -172,6 +190,15 @@ module Maquina
|
|
|
172
190
|
say " 4. Visit /registration/new # Sign-up page"
|
|
173
191
|
end
|
|
174
192
|
say ""
|
|
193
|
+
if @encryption_written
|
|
194
|
+
say "Added Active Record encryption keys to config/credentials.yml.enc (EmailVerification encrypts its code).", :yellow
|
|
195
|
+
say ""
|
|
196
|
+
elsif @encryption_pending
|
|
197
|
+
say "EmailVerification encrypts its code. Generate keys and add them to your credentials:", :yellow
|
|
198
|
+
say " bin/rails db:encryption:init"
|
|
199
|
+
say " bin/rails credentials:edit"
|
|
200
|
+
say ""
|
|
201
|
+
end
|
|
175
202
|
say "Optional:", :yellow
|
|
176
203
|
say " - Edit app/controllers/concerns/authentication.rb to customize redirect URLs"
|
|
177
204
|
say " - Edit config/locales/clave.*.yml to customize messages"
|
|
@@ -181,6 +208,27 @@ module Maquina
|
|
|
181
208
|
|
|
182
209
|
private
|
|
183
210
|
|
|
211
|
+
# The app's credentials, or nil when there is no key to decrypt them with.
|
|
212
|
+
def app_credentials
|
|
213
|
+
key_path = File.join(destination_root, "config/master.key")
|
|
214
|
+
return unless File.exist?(key_path) || ENV["RAILS_MASTER_KEY"].present?
|
|
215
|
+
|
|
216
|
+
ActiveSupport::EncryptedConfiguration.new(
|
|
217
|
+
config_path: File.join(destination_root, "config/credentials.yml.enc"),
|
|
218
|
+
key_path: key_path, env_key: "RAILS_MASTER_KEY", raise_if_missing_key: false
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The same shape `bin/rails db:encryption:init` prints.
|
|
223
|
+
def encryption_keys_yaml
|
|
224
|
+
<<~YAML
|
|
225
|
+
active_record_encryption:
|
|
226
|
+
primary_key: #{SecureRandom.alphanumeric(32)}
|
|
227
|
+
deterministic_key: #{SecureRandom.alphanumeric(32)}
|
|
228
|
+
key_derivation_salt: #{SecureRandom.alphanumeric(32)}
|
|
229
|
+
YAML
|
|
230
|
+
end
|
|
231
|
+
|
|
184
232
|
def migration_version
|
|
185
233
|
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
186
234
|
end
|
|
@@ -26,7 +26,16 @@ class Registration::VerificationsController < ApplicationController
|
|
|
26
26
|
return
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
# Too many wrong guesses: kill the code so a fresh one is required.
|
|
30
|
+
if @verification.locked?
|
|
31
|
+
@verification.update!(expires_at: Time.current)
|
|
32
|
+
Rails.logger.warn "[AUTH] Verification locked after #{@verification.attempts} attempts for #{@email} from #{request.remote_ip}"
|
|
33
|
+
flash.now[:alert] = t("flash.registrations.code_locked")
|
|
34
|
+
render :new, status: :unprocessable_entity
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
unless @verification.code_matches?(params[:code])
|
|
30
39
|
@verification.increment!(:attempts)
|
|
31
40
|
Rails.logger.warn "[AUTH] Invalid verification code attempt for #{@email} from #{request.remote_ip} (attempt #{@verification.attempts})"
|
|
32
41
|
flash.now[:alert] = t("flash.registrations.invalid_code")
|
data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt
CHANGED
|
@@ -27,7 +27,16 @@ class Session::VerificationsController < ApplicationController
|
|
|
27
27
|
return
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
# Too many wrong guesses: kill the code so a fresh one is required.
|
|
31
|
+
if @verification.locked?
|
|
32
|
+
@verification.update!(expires_at: Time.current)
|
|
33
|
+
Rails.logger.warn "[AUTH] Verification locked after #{@verification.attempts} attempts for #{@email} from #{request.remote_ip}"
|
|
34
|
+
flash.now[:alert] = t("flash.sessions.code_locked")
|
|
35
|
+
render :new, status: :unprocessable_entity
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless @verification.code_matches?(params[:code])
|
|
31
40
|
@verification.increment!(:attempts)
|
|
32
41
|
Rails.logger.warn "[AUTH] Invalid verification code attempt for #{@email} from #{request.remote_ip} (attempt #{@verification.attempts})"
|
|
33
42
|
flash.now[:alert] = t("flash.sessions.invalid_code")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
class EmailVerification < ApplicationRecord
|
|
2
2
|
VERIFICATION_TYPES = %w[signup login].freeze
|
|
3
3
|
COOLDOWN_MINUTES = 15
|
|
4
|
+
MAX_ATTEMPTS = 5
|
|
4
5
|
|
|
5
6
|
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
6
7
|
validates :code, presence: true
|
|
@@ -10,6 +11,10 @@ class EmailVerification < ApplicationRecord
|
|
|
10
11
|
normalizes :email, with: ->(email) { email.strip.downcase }
|
|
11
12
|
normalizes :code, with: ->(code) { code.strip.upcase }
|
|
12
13
|
|
|
14
|
+
# Encrypted at rest, not digested: the mail goes out through deliver_later,
|
|
15
|
+
# whose job reloads this row by GlobalID and must read the code back.
|
|
16
|
+
encrypts :code
|
|
17
|
+
|
|
13
18
|
scope :pending, -> { where(verified_at: nil) }
|
|
14
19
|
scope :expired, -> { where("expires_at < ?", Time.current) }
|
|
15
20
|
scope :active, -> { pending.where("expires_at >= ?", Time.current) }
|
|
@@ -20,6 +25,19 @@ class EmailVerification < ApplicationRecord
|
|
|
20
25
|
expires_at < Time.current
|
|
21
26
|
end
|
|
22
27
|
|
|
28
|
+
# A code is single-use against a bounded number of guesses: once the wrong-code
|
|
29
|
+
# count hits the ceiling the record is dead regardless of how many IPs an
|
|
30
|
+
# attacker rotates through, since each code only ever maps to one row.
|
|
31
|
+
def locked?
|
|
32
|
+
attempts >= MAX_ATTEMPTS
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Constant-time, so response timing reveals nothing about how close a guess
|
|
36
|
+
# came. Normalized the same way the stored code is.
|
|
37
|
+
def code_matches?(candidate)
|
|
38
|
+
ActiveSupport::SecurityUtils.secure_compare(code, candidate.to_s.strip.upcase)
|
|
39
|
+
end
|
|
40
|
+
|
|
23
41
|
def verified?
|
|
24
42
|
verified_at.present?
|
|
25
43
|
end
|
|
@@ -8,6 +8,7 @@ en:
|
|
|
8
8
|
code_sent: "We sent you a verification code."
|
|
9
9
|
invalid_code: "The code is incorrect. Please try again."
|
|
10
10
|
code_expired: "The code has expired. Request a new one."
|
|
11
|
+
code_locked: "Too many incorrect tries. Request a new code to keep going."
|
|
11
12
|
code_resent: "We sent you a new code."
|
|
12
13
|
cooldown: "You must wait %{minutes} minutes before requesting another code."
|
|
13
14
|
code_already_sent: "We already sent you a code. Check your email (including spam). You can request a new one in %{minutes} minutes."
|
|
@@ -19,6 +20,7 @@ en:
|
|
|
19
20
|
email_taken: "An account with this email already exists. Would you like to sign in?"
|
|
20
21
|
invalid_code: "The code is incorrect. Please try again."
|
|
21
22
|
code_expired: "The code has expired. Request a new one."
|
|
23
|
+
code_locked: "Too many incorrect tries. Request a new code to keep going."
|
|
22
24
|
code_resent: "We sent you a new code."
|
|
23
25
|
cooldown: "You must wait %{minutes} minutes before requesting another code."
|
|
24
26
|
code_already_sent: "We already sent you a code. Check your email (including spam). You can request a new one in %{minutes} minutes."
|
|
@@ -8,6 +8,7 @@ es:
|
|
|
8
8
|
code_sent: "Te enviamos un codigo de verificacion."
|
|
9
9
|
invalid_code: "El codigo es incorrecto. Por favor, intenta de nuevo."
|
|
10
10
|
code_expired: "El codigo ha expirado. Solicita uno nuevo."
|
|
11
|
+
code_locked: "Demasiados intentos incorrectos. Solicita un codigo nuevo para continuar."
|
|
11
12
|
code_resent: "Te enviamos un nuevo codigo."
|
|
12
13
|
cooldown: "Debes esperar %{minutes} minutos antes de solicitar otro codigo."
|
|
13
14
|
code_already_sent: "Ya te enviamos un codigo. Revisa tu correo (incluyendo spam). Podras solicitar uno nuevo en %{minutes} minutos."
|
|
@@ -19,6 +20,7 @@ es:
|
|
|
19
20
|
email_taken: "Ya existe una cuenta con este correo. Quieres iniciar sesion?"
|
|
20
21
|
invalid_code: "El codigo es incorrecto. Por favor, intenta de nuevo."
|
|
21
22
|
code_expired: "El codigo ha expirado. Solicita uno nuevo."
|
|
23
|
+
code_locked: "Demasiados intentos incorrectos. Solicita un codigo nuevo para continuar."
|
|
22
24
|
code_resent: "Te enviamos un nuevo codigo."
|
|
23
25
|
cooldown: "Debes esperar %{minutes} minutos antes de solicitar otro codigo."
|
|
24
26
|
code_already_sent: "Ya te enviamos un codigo. Revisa tu correo (incluyendo spam). Podras solicitar uno nuevo en %{minutes} minutos."
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Adds a Content Security Policy violation report endpoint and shows the
|
|
3
|
+
reports on the backstage Security page. Needs maquina:security, whose
|
|
4
|
+
`security` database stores them.
|
|
5
|
+
|
|
6
|
+
Installs CspReportsController (POST <path>, no session or CSRF, both report
|
|
7
|
+
formats, 64 KB cap, browser-extension noise dropped), the
|
|
8
|
+
Security::CspViolation model and its migration in db/security_migrate, the
|
|
9
|
+
csp_violations table in db/security_schema.rb, a daily purge in
|
|
10
|
+
config/recurring.yml and a section on the Security page. It points the
|
|
11
|
+
policy's report-uri at the endpoint, gives anonymous requests a nonce
|
|
12
|
+
(Rails' session-id nonce is empty without a session), exempts the endpoint
|
|
13
|
+
from the general Rack::Attack throttle and caps it on its own. Anything it
|
|
14
|
+
cannot place safely is listed after the install instead of being forced.
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
rails g maquina:csp_reports
|
|
18
|
+
rails g maquina:csp_reports --path /security/csp-reports
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/migration"
|
|
3
|
+
|
|
4
|
+
module Maquina
|
|
5
|
+
module Generators
|
|
6
|
+
class CspReportsGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
THROTTLE_LIMIT = 100
|
|
12
|
+
THROTTLE_PERIOD = "5.minutes"
|
|
13
|
+
# Rails' default nonce generator, empty for a request without a session.
|
|
14
|
+
SESSION_NONCE = "->(request) { request.session.id.to_s }"
|
|
15
|
+
POLICY_BLOCK = /^ config\.content_security_policy do \|policy\|\n/
|
|
16
|
+
# The general throttle's exemptions, in the two shapes maquina:rack_attack
|
|
17
|
+
# has shipped: a start_with? list, and a STATIC_FILE_PREFIXES word array.
|
|
18
|
+
COUNTED_IP_EXEMPTIONS = /req\.path\.start_with\?\("\/assets", "\/rails\/active_storage"/
|
|
19
|
+
STATIC_FILE_PREFIXES = /(STATIC_FILE_PREFIXES = %w\[)([^\]]*)(\])/
|
|
20
|
+
SECURITY_PAGE_END = /\n<\/div>\n\z/
|
|
21
|
+
|
|
22
|
+
class_option :path, type: :string, default: "/csp-reports",
|
|
23
|
+
desc: "Path browsers post violation reports to"
|
|
24
|
+
class_option :quiet, type: :boolean, default: false,
|
|
25
|
+
desc: "Suppress post-install instructions"
|
|
26
|
+
|
|
27
|
+
def self.next_migration_number(dirname)
|
|
28
|
+
current = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
29
|
+
existing = Dir.glob("#{dirname}/[0-9]*_*.rb").map { |f| File.basename(f).split("_", 2).first }.max
|
|
30
|
+
(existing && existing >= current) ? existing.succ : current
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# 1. maquina:security owns the database the reports go in.
|
|
34
|
+
def require_security_database
|
|
35
|
+
return if File.exist?(File.join(destination_root, "db/security_schema.rb"))
|
|
36
|
+
|
|
37
|
+
raise Thor::Error, "maquina:csp_reports stores reports in the `security` database. Run `rails g maquina:security --prefix /admin` first."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 2. Models
|
|
41
|
+
def create_models
|
|
42
|
+
copy_file "app/models/security/csp_violation.rb", "app/models/security/csp_violation.rb"
|
|
43
|
+
copy_file "app/models/security/csp_violation_report.rb", "app/models/security/csp_violation_report.rb"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 3. Controller and route
|
|
47
|
+
def create_controller
|
|
48
|
+
copy_file "app/controllers/csp_reports_controller.rb", "app/controllers/csp_reports_controller.rb"
|
|
49
|
+
route %(post "#{options[:path]}", to: "csp_reports#create", as: :csp_reports)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 4. The table: a migration for an existing security database, the schema
|
|
53
|
+
# for a fresh one (db:prepare loads the schema only on create).
|
|
54
|
+
def create_table
|
|
55
|
+
return if File.read(schema_path).include?(%("csp_violations"))
|
|
56
|
+
|
|
57
|
+
migration_template "db/create_csp_violations.rb.tt", "db/security_migrate/create_csp_violations.rb"
|
|
58
|
+
version = Dir.glob(File.join(destination_root, "db/security_migrate/*_create_csp_violations.rb"))
|
|
59
|
+
.map { |f| File.basename(f).split("_", 2).first }.max
|
|
60
|
+
gsub_file "db/security_schema.rb", /define\(version: [\d_]+\)/, "define(version: #{version_with_underscores(version)})"
|
|
61
|
+
inject_into_file "db/security_schema.rb", csp_violations_table, before: /^end\s*\z/
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 5. The policy reports to the endpoint, and anonymous requests get a nonce.
|
|
65
|
+
def configure_content_security_policy
|
|
66
|
+
path = "config/initializers/content_security_policy.rb"
|
|
67
|
+
content = File.exist?(File.join(destination_root, path)) ? File.read(File.join(destination_root, path)) : ""
|
|
68
|
+
|
|
69
|
+
if content.include?("report_uri")
|
|
70
|
+
@report_uri_pending = !content.include?(%(report_uri "#{options[:path]}"))
|
|
71
|
+
elsif content.match?(POLICY_BLOCK)
|
|
72
|
+
inject_into_file path, report_uri_line, after: POLICY_BLOCK
|
|
73
|
+
else
|
|
74
|
+
@report_uri_pending = true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if content.include?(SESSION_NONCE)
|
|
78
|
+
gsub_file path, SESSION_NONCE, "->(request) { request.session.id.to_s.presence || SecureRandom.base64(16) }"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 6. Rack::Attack: out of the general throttle, capped on its own.
|
|
83
|
+
def configure_rack_attack
|
|
84
|
+
path = "config/initializers/rack_attack.rb"
|
|
85
|
+
full_path = File.join(destination_root, path)
|
|
86
|
+
return @rack_attack_pending = true unless File.exist?(full_path)
|
|
87
|
+
|
|
88
|
+
content = File.read(full_path)
|
|
89
|
+
return if content.include?(%("csp/ip"))
|
|
90
|
+
|
|
91
|
+
if content.match?(COUNTED_IP_EXEMPTIONS)
|
|
92
|
+
# The match stops before the call's closing paren, which stays in place.
|
|
93
|
+
gsub_file path, COUNTED_IP_EXEMPTIONS, 'req.path.start_with?("/assets", "/rails/active_storage", ' + %("#{options[:path]}")
|
|
94
|
+
elsif content.match?(STATIC_FILE_PREFIXES)
|
|
95
|
+
gsub_file path, STATIC_FILE_PREFIXES, "\\1\\2 #{options[:path]}\\3"
|
|
96
|
+
else
|
|
97
|
+
@rack_attack_pending = true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
append_to_file path, csp_throttle
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 7. Daily purge
|
|
104
|
+
def add_recurring_purge
|
|
105
|
+
path = File.join(destination_root, "config/recurring.yml")
|
|
106
|
+
content = File.exist?(path) ? File.read(path) : ""
|
|
107
|
+
return if content.include?("purge_csp_violations")
|
|
108
|
+
|
|
109
|
+
if content.match?(/^production:\n/)
|
|
110
|
+
inject_into_file "config/recurring.yml", recurring_entry, after: /^production:\n/
|
|
111
|
+
else
|
|
112
|
+
@recurring_pending = true
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# 8. Security page section
|
|
117
|
+
def add_security_page_section
|
|
118
|
+
copy_file "app/views/backstage/security/_csp_violations.html.erb",
|
|
119
|
+
"app/views/backstage/security/_csp_violations.html.erb"
|
|
120
|
+
|
|
121
|
+
page = "app/views/backstage/security/show.html.erb"
|
|
122
|
+
full_path = File.join(destination_root, page)
|
|
123
|
+
content = File.exist?(full_path) ? File.read(full_path) : ""
|
|
124
|
+
return if content.include?("backstage/security/csp_violations")
|
|
125
|
+
|
|
126
|
+
if content.match?(SECURITY_PAGE_END)
|
|
127
|
+
inject_into_file page, security_page_section, before: SECURITY_PAGE_END
|
|
128
|
+
else
|
|
129
|
+
@page_pending = true
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# 9. Post-install message
|
|
134
|
+
def show_post_install
|
|
135
|
+
return if options[:quiet]
|
|
136
|
+
|
|
137
|
+
say ""
|
|
138
|
+
say "Content Security Policy reports are set up at POST #{options[:path]}.", :green
|
|
139
|
+
say ""
|
|
140
|
+
say "Next steps:", :yellow
|
|
141
|
+
say " - Add the table: bin/rails db:migrate (db:prepare on deploy)"
|
|
142
|
+
say " - Watch the Security page while the policy is report-only, then set"
|
|
143
|
+
say " content_security_policy_report_only = false to enforce it."
|
|
144
|
+
if @report_uri_pending
|
|
145
|
+
say ""
|
|
146
|
+
say "Point the policy at the endpoint in config/initializers/content_security_policy.rb:", :yellow
|
|
147
|
+
say " #{report_uri_line.strip}"
|
|
148
|
+
end
|
|
149
|
+
if @rack_attack_pending
|
|
150
|
+
say ""
|
|
151
|
+
say "Exempt #{options[:path]} from the general throttle in config/initializers/rack_attack.rb and add:", :yellow
|
|
152
|
+
say csp_throttle
|
|
153
|
+
end
|
|
154
|
+
if @recurring_pending
|
|
155
|
+
say ""
|
|
156
|
+
say "Add to config/recurring.yml under your production (or shared) key:", :yellow
|
|
157
|
+
say recurring_entry
|
|
158
|
+
end
|
|
159
|
+
if @page_pending
|
|
160
|
+
say ""
|
|
161
|
+
say "Render the section on the Security page:", :yellow
|
|
162
|
+
say security_page_section
|
|
163
|
+
end
|
|
164
|
+
say ""
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def schema_path = File.join(destination_root, "db/security_schema.rb")
|
|
170
|
+
|
|
171
|
+
def migration_version
|
|
172
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def version_with_underscores(version)
|
|
176
|
+
version.sub(/\A(\d{4})(\d{2})(\d{2})(\d{6})\z/, '\1_\2_\3_\4')
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def report_uri_line
|
|
180
|
+
%( # Violations go to CspReportsController (maquina:csp_reports). report-uri\n) +
|
|
181
|
+
%( # alone: adding report-to switches Chrome to the batched Reporting API.\n) +
|
|
182
|
+
%( policy.report_uri "#{options[:path]}"\n)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def csp_throttle
|
|
186
|
+
<<~RUBY
|
|
187
|
+
|
|
188
|
+
# Content Security Policy reports (maquina:csp_reports): exempt from the
|
|
189
|
+
# general throttle above — one page with a dozen violations would spend a
|
|
190
|
+
# reader's budget — and capped on their own.
|
|
191
|
+
Rack::Attack.throttle("csp/ip", limit: #{THROTTLE_LIMIT}, period: #{THROTTLE_PERIOD}) do |req|
|
|
192
|
+
(req.env["action_dispatch.remote_ip"]&.to_s || req.ip) if req.path == "#{options[:path]}" && req.post?
|
|
193
|
+
end
|
|
194
|
+
RUBY
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def recurring_entry
|
|
198
|
+
<<~YAML.gsub(/^/, " ")
|
|
199
|
+
purge_csp_violations:
|
|
200
|
+
command: "Security::CspViolation.expired.delete_all"
|
|
201
|
+
schedule: every day at 4:30am
|
|
202
|
+
YAML
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def security_page_section
|
|
206
|
+
%(\n\n <%= render "backstage/security/csp_violations", report: Security::CspViolationReport.new %>)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def csp_violations_table
|
|
210
|
+
<<~RUBY.gsub(/^(?=.)/, " ")
|
|
211
|
+
|
|
212
|
+
create_table "csp_violations", force: :cascade do |t|
|
|
213
|
+
t.integer "column_number"
|
|
214
|
+
t.datetime "created_at", null: false
|
|
215
|
+
t.string "blocked_uri"
|
|
216
|
+
t.string "disposition"
|
|
217
|
+
t.string "document_uri"
|
|
218
|
+
t.string "effective_directive", null: false
|
|
219
|
+
t.string "ip"
|
|
220
|
+
t.integer "line_number"
|
|
221
|
+
t.string "sample"
|
|
222
|
+
t.string "source_file"
|
|
223
|
+
t.string "user_agent"
|
|
224
|
+
t.index ["created_at"], name: "index_csp_violations_on_created_at"
|
|
225
|
+
t.index ["effective_directive", "created_at"], name: "index_csp_violations_on_effective_directive_and_created_at"
|
|
226
|
+
end
|
|
227
|
+
RUBY
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Receives Content Security Policy violation reports. Browsers post them with no
|
|
2
|
+
# cookie and no CSRF token, in either the legacy report-uri shape
|
|
3
|
+
# (application/csp-report) or the Reporting API's (application/reports+json),
|
|
4
|
+
# so this is an API controller that reads the raw body itself.
|
|
5
|
+
#
|
|
6
|
+
# Always 204 for a report it could read — a browser does nothing with the
|
|
7
|
+
# answer — and best effort: a locked security database must never turn a
|
|
8
|
+
# report into a 500 that the browser retries.
|
|
9
|
+
class CspReportsController < ActionController::API
|
|
10
|
+
MAX_BYTES = 64.kilobytes
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
body = request.body.read(MAX_BYTES + 1).to_s
|
|
14
|
+
return head 413 if body.bytesize > MAX_BYTES
|
|
15
|
+
|
|
16
|
+
Security::CspViolation.record_report!(body, ip: request.remote_ip, user_agent: request.user_agent)
|
|
17
|
+
head :no_content
|
|
18
|
+
rescue JSON::ParserError
|
|
19
|
+
head :bad_request
|
|
20
|
+
rescue => error
|
|
21
|
+
Rails.logger.error "[CSP] report not recorded: #{error.class}: #{error.message}"
|
|
22
|
+
head :no_content
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# One Content Security Policy violation a browser reported to CspReportsController.
|
|
2
|
+
# Rows are written once and never updated; they are what tells you whether the
|
|
3
|
+
# policy is ready to enforce (report-only) or what it is breaking (enforced).
|
|
4
|
+
#
|
|
5
|
+
# Browsers send two shapes. The legacy `report-uri` one is a single object,
|
|
6
|
+
# `{"csp-report": {"blocked-uri": ..., ...}}` (Content-Type application/csp-report);
|
|
7
|
+
# the Reporting API one is an array of `{"type": "csp-violation", "body": {...}}`
|
|
8
|
+
# (application/reports+json) with camelCase keys. Both normalize to the same row.
|
|
9
|
+
class Security::CspViolation < Security::Record
|
|
10
|
+
RETENTION = 30.days
|
|
11
|
+
|
|
12
|
+
# Violations caused by the reader's own browser extensions say nothing about
|
|
13
|
+
# the app, and they are most of the volume.
|
|
14
|
+
NOISE_SCHEMES = %w[chrome-extension moz-extension safari-extension safari-web-extension ms-browser-extension].freeze
|
|
15
|
+
|
|
16
|
+
FIELD_LIMIT = 1024
|
|
17
|
+
SAMPLE_LIMIT = 256
|
|
18
|
+
|
|
19
|
+
LEGACY_KEYS = {
|
|
20
|
+
"document-uri" => :document_uri, "blocked-uri" => :blocked_uri,
|
|
21
|
+
"effective-directive" => :effective_directive, "violated-directive" => :effective_directive,
|
|
22
|
+
"disposition" => :disposition, "source-file" => :source_file,
|
|
23
|
+
"line-number" => :line_number, "column-number" => :column_number, "script-sample" => :sample
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
REPORTING_API_KEYS = {
|
|
27
|
+
"documentURL" => :document_uri, "blockedURL" => :blocked_uri,
|
|
28
|
+
"effectiveDirective" => :effective_directive, "disposition" => :disposition,
|
|
29
|
+
"sourceFile" => :source_file, "lineNumber" => :line_number,
|
|
30
|
+
"columnNumber" => :column_number, "sample" => :sample
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
validates :effective_directive, presence: true
|
|
34
|
+
|
|
35
|
+
scope :recent, -> { order(created_at: :desc, id: :desc) }
|
|
36
|
+
scope :within, ->(window) { where(created_at: window.ago..) }
|
|
37
|
+
scope :expired, -> { where(created_at: ...RETENTION.ago) }
|
|
38
|
+
|
|
39
|
+
# Records every violation in a raw report body; returns how many were kept.
|
|
40
|
+
# Raises JSON::ParserError on a body that isn't JSON.
|
|
41
|
+
def self.record_report!(raw, ip:, user_agent:)
|
|
42
|
+
violations = parse(raw).reject { |attrs| noise?(attrs) }
|
|
43
|
+
violations.each do |attrs|
|
|
44
|
+
create!(attrs.merge(ip:, user_agent: user_agent&.truncate(FIELD_LIMIT, omission: "")))
|
|
45
|
+
end
|
|
46
|
+
violations.size
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.parse(raw)
|
|
50
|
+
payload = JSON.parse(raw)
|
|
51
|
+
reports = payload.is_a?(Array) ? payload : [payload]
|
|
52
|
+
|
|
53
|
+
reports.filter_map do |report|
|
|
54
|
+
next unless report.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
if report.key?("csp-report")
|
|
57
|
+
normalize(report["csp-report"], LEGACY_KEYS)
|
|
58
|
+
elsif report["type"] == "csp-violation"
|
|
59
|
+
normalize(report["body"], REPORTING_API_KEYS)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.noise?(attrs)
|
|
65
|
+
[attrs[:blocked_uri], attrs[:source_file]].compact.any? do |uri|
|
|
66
|
+
NOISE_SCHEMES.any? { |scheme| uri.start_with?("#{scheme}:") }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.normalize(body, keys)
|
|
71
|
+
return unless body.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
attrs = keys.each_with_object({}) do |(key, attribute), acc|
|
|
74
|
+
acc[attribute] ||= body[key] if body.key?(key)
|
|
75
|
+
end
|
|
76
|
+
# The directive alone (`script-src`), not the whole violated policy line.
|
|
77
|
+
attrs[:effective_directive] = attrs[:effective_directive].to_s.split.first
|
|
78
|
+
# URLs lose their query and fragment: a reported URL can carry a token.
|
|
79
|
+
%i[document_uri blocked_uri source_file].each { |key| attrs[key] = strip_url(attrs[key]) }
|
|
80
|
+
attrs[:sample] = attrs[:sample]&.to_s&.truncate(SAMPLE_LIMIT, omission: "")
|
|
81
|
+
%i[line_number column_number].each { |key| attrs[key] = attrs[key]&.to_i }
|
|
82
|
+
attrs.transform_values { |value| value.is_a?(String) ? value.truncate(FIELD_LIMIT, omission: "") : value }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.strip_url(value)
|
|
86
|
+
value&.to_s&.split(/[?#]/, 2)&.first
|
|
87
|
+
end
|
|
88
|
+
private_class_method :normalize, :strip_url
|
|
89
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# What the Security page shows about Content Security Policy: the last `window`
|
|
2
|
+
# of reported violations, grouped by what was blocked, where and why.
|
|
3
|
+
class Security::CspViolationReport
|
|
4
|
+
Group = Data.define(:effective_directive, :blocked_uri, :document_uri, :count, :last_seen)
|
|
5
|
+
|
|
6
|
+
attr_reader :window, :limit
|
|
7
|
+
|
|
8
|
+
def initialize(window: 7.days, limit: 10)
|
|
9
|
+
@window = window
|
|
10
|
+
@limit = limit
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def violations = Security::CspViolation.within(window)
|
|
14
|
+
|
|
15
|
+
def empty? = violations.none?
|
|
16
|
+
|
|
17
|
+
def total = violations.count
|
|
18
|
+
|
|
19
|
+
# The same blocked resource on the same page under the same directive, most
|
|
20
|
+
# frequent first — one row per thing to fix, however many readers hit it.
|
|
21
|
+
def top
|
|
22
|
+
violations.group(:effective_directive, :blocked_uri, :document_uri)
|
|
23
|
+
.order(Arel.sql("COUNT(*) DESC, MAX(created_at) DESC")).limit(limit)
|
|
24
|
+
.pluck(:effective_directive, :blocked_uri, :document_uri, Arel.sql("COUNT(*)"), Arel.sql("MAX(created_at)"))
|
|
25
|
+
.map do |directive, blocked, document, count, last|
|
|
26
|
+
Group.new(effective_directive: directive, blocked_uri: blocked, document_uri: document,
|
|
27
|
+
count:, last_seen: last.is_a?(String) ? ActiveSupport::TimeZone["UTC"].parse(last) : last)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|