trek 2.3.2 → 2.4.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.
Files changed (165) hide show
  1. checksums.yaml +4 -4
  2. data/.node-version +1 -1
  3. data/.yarn/releases/yarn-4.18.1.cjs +1000 -0
  4. data/.yarnrc.yml +3 -5
  5. data/AGENTS.md +7 -4
  6. data/CHANGELOG.md +48 -0
  7. data/Gemfile.lock +35 -1
  8. data/Makefile +12 -0
  9. data/app/assets/icons/trek/passkey.svg +5 -0
  10. data/app/assets/stylesheets/trek/base.css +6 -0
  11. data/app/components/trek/entries/item_component/item_component.css +6 -0
  12. data/app/components/trek/form/image_field_component/image_field_component.html.slim +9 -0
  13. data/app/components/trek/form/password_strength_component/password_strength_component.css +56 -0
  14. data/app/components/trek/form/password_strength_component/password_strength_component.html.slim +8 -0
  15. data/app/components/trek/form/password_strength_component/password_strength_component.js +74 -0
  16. data/app/components/trek/form/password_strength_component.rb +29 -0
  17. data/app/components/trek/gate_component/gate_component.css +7 -0
  18. data/app/components/trek/icon/sprite_component.rb +3 -2
  19. data/app/components/trek/section_component/section_component.css +22 -0
  20. data/app/components/trek/section_component/section_component.html.slim +5 -0
  21. data/app/components/trek/section_component/section_component.js +1 -0
  22. data/app/components/trek/section_component.rb +14 -0
  23. data/app/controllers/concerns/trek/auditable.rb +103 -0
  24. data/app/controllers/concerns/trek/filters.rb +2 -0
  25. data/app/controllers/concerns/trek/passkeys.rb +37 -0
  26. data/app/controllers/trek/csp_reports_controller.rb +62 -0
  27. data/app/controllers/trek/resource_controller.rb +7 -1
  28. data/app/helpers/trek/audit_logs_helper.rb +21 -0
  29. data/app/helpers/trek/user_agents_helper.rb +36 -0
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/passkey_controller.js +118 -0
  32. data/app/javascript/trek.js +4 -0
  33. data/app/jobs/trek/audit_log/purge_job.rb +18 -0
  34. data/app/jobs/trek/purge_expired_sessions_job.rb +14 -0
  35. data/app/jobs/trek/resolve_session_hostname_job.rb +17 -0
  36. data/app/models/concerns/trek/audit_log.rb +30 -0
  37. data/app/models/concerns/trek/users/passkeys.rb +75 -0
  38. data/app/models/concerns/trek/users/password_history.rb +51 -0
  39. data/app/models/concerns/trek/users/password_policy.rb +85 -0
  40. data/app/models/concerns/trek/users/password_renewal.rb +51 -0
  41. data/app/models/concerns/trek/users/session_lifetime.rb +39 -0
  42. data/app/models/concerns/trek/users/sign_in_lockout.rb +67 -0
  43. data/app/models/concerns/trek/users/sign_ins.rb +31 -0
  44. data/app/models/concerns/trek/users/validations.rb +3 -2
  45. data/app/models/trek/feature.rb +6 -0
  46. data/app/models/trek/pwned_passwords.rb +40 -0
  47. data/app/models/trek/user_session.rb +10 -0
  48. data/app/policies/trek/audit_log_policy.rb +34 -0
  49. data/app/policies/trek/user_policy.rb +5 -0
  50. data/app/validators/not_disposable_validator.rb +18 -0
  51. data/config/brakeman.ignore +22 -0
  52. data/config/i18n-tasks.yml +7 -0
  53. data/config/locales/trek.en.yml +144 -39
  54. data/config/locales/trek.fr.yml +145 -40
  55. data/docs/.vitepress/config.mjs +8 -3
  56. data/docs/deploy.md +2 -0
  57. data/docs/proxies.md +53 -0
  58. data/docs/reference/components.md +1 -0
  59. data/docs/reference/environment-variables.md +2 -0
  60. data/docs/reference/generators/admin-user.md +1 -1
  61. data/docs/reference/generators/audit-log.md +113 -0
  62. data/docs/reference/generators/install.md +12 -3
  63. data/docs/reference/generators/security-headers.md +109 -0
  64. data/docs/reference/index.md +3 -0
  65. data/docs/reference/models/tag.md +4 -2
  66. data/docs/reference/models/user.md +123 -3
  67. data/docs/reference/validators.md +94 -0
  68. data/docs/security.md +90 -0
  69. data/lefthook.yml +8 -0
  70. data/lib/generators/trek/install/admin/user_password_resets_generator.rb +5 -0
  71. data/lib/generators/trek/install/admin/user_sessions_generator.rb +5 -0
  72. data/lib/generators/trek/install/admin/users_generator.rb +23 -0
  73. data/lib/generators/trek/install/audit_log_generator.rb +56 -0
  74. data/lib/generators/trek/install/authentication_generator.rb +77 -3
  75. data/lib/generators/trek/install/esbuild_generator.rb +1 -2
  76. data/lib/generators/trek/install/mailer_generator.rb +18 -0
  77. data/lib/generators/trek/install/model_specs_generator.rb +6 -4
  78. data/lib/generators/trek/install/postcss_generator.rb +1 -4
  79. data/lib/generators/trek/install/security_headers_generator.rb +49 -0
  80. data/lib/generators/trek/install/yarn_generator.rb +1 -1
  81. data/lib/generators/trek/install_generator.rb +62 -54
  82. data/lib/generators/trek/taxonomies_generator.rb +5 -0
  83. data/lib/generators/trek/templates/.gitlab-ci.yml +18 -2
  84. data/lib/generators/trek/templates/controllers/admin/active_sessions_controller.rb +21 -0
  85. data/lib/generators/trek/templates/controllers/admin/audit_logs_controller.rb +30 -0
  86. data/lib/generators/trek/templates/controllers/admin/passkey_sessions_controller.rb +41 -0
  87. data/lib/generators/trek/templates/controllers/admin/passkeys_controller.rb +37 -0
  88. data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +4 -4
  89. data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +53 -4
  90. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +20 -0
  91. data/lib/generators/trek/templates/controllers/concerns/trek/authentication.rb +58 -7
  92. data/lib/generators/trek/templates/helpers/application_helper.rb +0 -4
  93. data/lib/generators/trek/templates/initializers/audit_log.rb +11 -0
  94. data/lib/generators/trek/templates/initializers/content_security_policy.rb +49 -0
  95. data/lib/generators/trek/templates/initializers/permissions_policy.rb +19 -0
  96. data/lib/generators/trek/templates/jobs/admin/password_reset_job.rb +9 -0
  97. data/lib/generators/trek/templates/layouts/admin/auth.html.slim +0 -5
  98. data/lib/generators/trek/templates/layouts/admin.html.slim +0 -3
  99. data/lib/generators/trek/templates/mailers/admin/passwords_mailer.rb +1 -1
  100. data/lib/generators/trek/templates/mailers/admin_mailer.rb +28 -1
  101. data/lib/generators/trek/templates/migrations/audit_logs.rb.erb +27 -0
  102. data/lib/generators/trek/templates/migrations/tags.rb.erb +6 -1
  103. data/lib/generators/trek/templates/migrations/user_passkeys.rb.erb +24 -0
  104. data/lib/generators/trek/templates/migrations/user_password_renewal.rb.erb +8 -0
  105. data/lib/generators/trek/templates/migrations/user_previous_passwords.rb.erb +10 -0
  106. data/lib/generators/trek/templates/migrations/user_session_tracking.rb.erb +11 -0
  107. data/lib/generators/trek/templates/migrations/user_sign_in_lockout.rb.erb +7 -0
  108. data/lib/generators/trek/templates/migrations/user_sign_ins.rb.erb +13 -0
  109. data/lib/generators/trek/templates/models/audit_log.rb +3 -0
  110. data/lib/generators/trek/templates/models/tag_category.rb +2 -1
  111. data/lib/generators/trek/templates/models/user/passkey.rb +17 -0
  112. data/lib/generators/trek/templates/models/user/previous_password.rb +4 -0
  113. data/lib/generators/trek/templates/models/user/session.rb +8 -0
  114. data/lib/generators/trek/templates/policies/audit_log.rb +4 -0
  115. data/lib/generators/trek/templates/spec/mailers/admin/passwords_mailer_spec.rb +16 -0
  116. data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +50 -0
  117. data/lib/generators/trek/templates/spec/models/audit_log_spec.rb +43 -0
  118. data/lib/generators/trek/templates/spec/models/tag_spec.rb +52 -0
  119. data/lib/generators/trek/templates/spec/models/user/session_spec.rb +68 -0
  120. data/lib/generators/trek/templates/spec/models/user_spec.rb +142 -4
  121. data/lib/generators/trek/templates/spec/requests/admin/active_sessions_spec.rb +119 -0
  122. data/lib/generators/trek/templates/spec/requests/admin/audit_logs_spec.rb +149 -0
  123. data/lib/generators/trek/templates/spec/requests/admin/passkey_sessions_spec.rb +103 -0
  124. data/lib/generators/trek/templates/spec/requests/admin/passkeys_spec.rb +104 -0
  125. data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +83 -0
  126. data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +95 -0
  127. data/lib/generators/trek/templates/spec/requests/admin/sign_in_notices_spec.rb +93 -0
  128. data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +43 -6
  129. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +25 -2
  130. data/lib/generators/trek/templates/views/admin/audit_logs/index.html.slim +35 -0
  131. data/lib/generators/trek/templates/views/admin/audit_logs/show.html.slim +50 -0
  132. data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +16 -6
  133. data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +2 -1
  134. data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +27 -1
  135. data/lib/generators/trek/templates/views/admin/users/_passkeys.html.slim +67 -0
  136. data/lib/generators/trek/templates/views/admin/users/_properties.html.slim +21 -0
  137. data/lib/generators/trek/templates/views/admin/users/_sessions.html.slim +70 -0
  138. data/lib/generators/trek/templates/views/admin/users/index.html.slim +13 -0
  139. data/lib/generators/trek/templates/views/admin/users/show.html.slim +10 -21
  140. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +22 -0
  141. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +16 -0
  142. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +22 -0
  143. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +16 -0
  144. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.html.slim +17 -0
  145. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.en.text.erb +10 -0
  146. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.html.slim +18 -0
  147. data/lib/generators/trek/templates/views/admin_mailer/sign_in_locked.fr.text.erb +10 -0
  148. data/lib/trek/admin_ip_allowlist.rb +52 -0
  149. data/lib/trek/csp_reports.rb +99 -0
  150. data/lib/trek/data/common_passwords.txt +857 -0
  151. data/lib/trek/data/passkey_providers.json +404 -0
  152. data/lib/trek/disposable_email_domains.rb +53 -0
  153. data/lib/trek/engine.rb +12 -0
  154. data/lib/trek/passkey_providers.rb +19 -0
  155. data/lib/trek/reverse_dns.rb +27 -0
  156. data/lib/trek/version.rb +1 -1
  157. data/lib/trek.rb +13 -0
  158. data/package.json +3 -5
  159. data/renovate.json +11 -1
  160. data/trek.gemspec +3 -0
  161. data/yarn.lock +1757 -2019
  162. metadata +132 -4
  163. data/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs +0 -541
  164. data/.yarn/releases/yarn-3.8.0.cjs +0 -875
  165. data/lib/generators/trek/templates/models/user_session.rb +0 -3
@@ -0,0 +1,70 @@
1
+ - if @object.last_sign_in_at?
2
+ ruby:
3
+ change_password = link_to(
4
+ t(".change_password"),
5
+ new_admin_user_password_reset_path(email: @object.email),
6
+ )
7
+ not_you = t(".not_you_html", change_password:)
8
+ previous_sign_in = Trek::SectionComponent.new(
9
+ title: t(".previous_sign_in"),
10
+ hint: not_you,
11
+ )
12
+ = render previous_sign_in
13
+ = render Trek::EntriesComponent.new do |entries|
14
+ ruby:
15
+ entries.with_item(item: @object) do |entry|
16
+ browser = user_agent_summary(@object.last_sign_in_user_agent)
17
+ entry.with_title(href: nil) { browser }
18
+ entry.with_detail { @object.last_sign_in_ip }
19
+ entry.with_detail do
20
+ t(".signed_in", date: l(@object.last_sign_in_at, format: :long))
21
+ end
22
+ end
23
+
24
+ = render Trek::SectionComponent.new(title: t(".title"), hint: t(".intro"))
25
+ = render Trek::EntriesComponent.new do |entries|
26
+ ruby:
27
+ sessions.each do |user_session|
28
+ entries.with_item(item: user_session) do |entry|
29
+ browser = user_agent_summary(user_session.user_agent)
30
+ entry.with_title(href: nil) { browser }
31
+ entry.with_detail do
32
+ ip = user_session.ip_address
33
+ hostname = user_session.ip_hostname
34
+ hostname ? tag.abbr(ip, title: hostname) : ip
35
+ end
36
+ entry.with_detail do
37
+ t(".signed_in", date: l(user_session.created_at, format: :long))
38
+ end
39
+ last_activity = time_ago_in_words(user_session.last_activity_at)
40
+ entry.with_detail { t(".last_activity", time: last_activity) }
41
+ entry.with_status do
42
+ if user_session == Current.session
43
+ t(".current")
44
+ else
45
+ render Trek::ButtonComponent.new(
46
+ href: admin_active_session_path(user_session),
47
+ text: t(".sign_out"),
48
+ size: :narrow,
49
+ data: { turbo_method: :delete },
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ - if sessions.size > 1
57
+ = render Trek::Form::ActionsComponent.new do |c|
58
+ ruby:
59
+ c.with_button(
60
+ href: %i[others admin active_sessions],
61
+ text: t(".sign_out_others"),
62
+ data: {
63
+ turbo_method: :delete,
64
+ turbo_confirm: t(".sign_out_others_confirm"),
65
+ turbo_confirm_button: t(".sign_out_others"),
66
+ turbo_confirm_color: "amber"
67
+ },
68
+ ) do |b|
69
+ b.with_icon("trek/logout")
70
+ end
@@ -12,6 +12,19 @@
12
12
  end
13
13
  end
14
14
 
15
+ if allowed_to?(:require_password_renewal?, model)
16
+ c.with_button(
17
+ href: [:require_password_renewal, :admin, :users],
18
+ text: t(".require_password_renewal"),
19
+ data: {
20
+ turbo_method: :post,
21
+ turbo_confirm: t(".require_password_renewal_confirm"),
22
+ turbo_confirm_button: t(".require_password_renewal"),
23
+ turbo_confirm_color: "amber"
24
+ },
25
+ )
26
+ end
27
+
15
28
  = render Trek::ListComponent.new(action: :show) do |c|
16
29
  ruby:
17
30
  @objects.each do |object|
@@ -18,24 +18,13 @@
18
18
  end
19
19
  end
20
20
 
21
- = render Trek::PropertiesComponent.new(model:) do |p|
22
- - if @object.name?
23
- = p.with_item :name
24
- = @object.name
25
-
26
- = p.with_item :role
27
- = t("user.roles.#{@object.role}")
28
-
29
- = p.with_item :email
30
- = mail_to @object.email
31
-
32
- = p.with_item :created_at
33
- = l(@object.created_at, default: "")
34
-
35
- - if policy_permitted_attribute?(:locale) && @object.locale?
36
- = p.with_item :locale
37
- = t("user.locale.#{@object.locale}")
38
-
39
- - if policy_permitted_attribute?(:theme) && @object.theme?
40
- = p.with_item :theme
41
- = t("user.theme.#{@object.theme}")
21
+ - if @object == Current.user
22
+ = render Trek::TabsComponent.new do |c|
23
+ - c.with_item(id: :profile, label: t(".profile"))
24
+ = render "properties"
25
+ - c.with_item(id: :sessions, label: t(".active_sessions"))
26
+ = render "sessions", sessions: @sessions
27
+ - c.with_item(id: :passkeys, label: t(".passkeys"))
28
+ = render "passkeys", passkeys: @passkeys
29
+ - else
30
+ = render "properties"
@@ -0,0 +1,22 @@
1
+ p Hello #{@user},
2
+ p
3
+ | Your #{t("application_name")} account was just signed in to
4
+ | from a browser it had never seen:
5
+ ul
6
+ li = l(@at, format: :long)
7
+ li = user_agent_summary(@user_agent)
8
+ li = @ip
9
+ p If it was you, there's nothing to do.
10
+ p
11
+ | If it wasn't, change your password right away,
12
+ | then sign out the browsers you don't recognize:
13
+ ul
14
+ li = link_to "Change your password",
15
+ @reset_url,
16
+ target: "_blank",
17
+ rel: "noopener"
18
+ li = link_to "See your active sessions",
19
+ @sessions_url,
20
+ target: "_blank",
21
+ rel: "noopener"
22
+ p The #{t("application_name")} team
@@ -0,0 +1,16 @@
1
+ Hello <%%= @user %>,
2
+
3
+ Your <%%= t("application_name") %> account was just signed in to from a browser it had never seen:
4
+
5
+ - <%%= l(@at, format: :long) %>
6
+ - <%%= user_agent_summary(@user_agent) %>
7
+ - <%%= @ip %>
8
+
9
+ If it was you, there's nothing to do.
10
+
11
+ If it wasn't, change your password right away, then sign out the browsers you don't recognize:
12
+
13
+ - Change your password: <%%= @reset_url %>
14
+ - See your active sessions: <%%= @sessions_url %>
15
+
16
+ The <%%= t("application_name") %> team
@@ -0,0 +1,22 @@
1
+ p Bonjour #{@user},
2
+ p
3
+ | Votre compte #{t("application_name")} vient d'être connecté
4
+ | depuis un navigateur jamais vu :
5
+ ul
6
+ li = l(@at, format: :long)
7
+ li = user_agent_summary(@user_agent)
8
+ li = @ip
9
+ p Si c'était vous, il n'y a rien à faire.
10
+ p
11
+ | Sinon, changez votre mot de passe tout de suite,
12
+ | puis déconnectez les navigateurs que vous ne reconnaissez pas :
13
+ ul
14
+ li = link_to "Changer votre mot de passe",
15
+ @reset_url,
16
+ target: "_blank",
17
+ rel: "noopener"
18
+ li = link_to "Voir vos sessions actives",
19
+ @sessions_url,
20
+ target: "_blank",
21
+ rel: "noopener"
22
+ p L'équipe #{t("application_name")}
@@ -0,0 +1,16 @@
1
+ Bonjour <%%= @user %>,
2
+
3
+ Votre compte <%%= t("application_name") %> vient d'être connecté depuis un navigateur jamais vu :
4
+
5
+ - <%%= l(@at, format: :long) %>
6
+ - <%%= user_agent_summary(@user_agent) %>
7
+ - <%%= @ip %>
8
+
9
+ Si c'était vous, il n'y a rien à faire.
10
+
11
+ Sinon, changez votre mot de passe tout de suite, puis déconnectez les navigateurs que vous ne reconnaissez pas :
12
+
13
+ - Changer votre mot de passe : <%%= @reset_url %>
14
+ - Voir vos sessions actives : <%%= @sessions_url %>
15
+
16
+ L'équipe <%%= t("application_name") %>
@@ -0,0 +1,17 @@
1
+ p Hello #{@user},
2
+ p
3
+ | Several attempts in a row to sign in to #{t("application_name")}
4
+ with your email failed, with a wrong password. To keep your account
5
+ safe, signing in is paused for a few minutes, a little longer after
6
+ each new failure.
7
+ p
8
+ | If it was you, wait a few minutes before trying again, or
9
+ =< link_to "choose a new password",
10
+ @reset_url,
11
+ target: "_blank",
12
+ rel: "noopener"
13
+ | .
14
+ p
15
+ | If it wasn't you, someone may be trying to guess your password: if
16
+ it's short or you use it elsewhere, choose a new one.
17
+ p The #{t("application_name")} team
@@ -0,0 +1,10 @@
1
+ Hello <%%= @user %>,
2
+
3
+ Several attempts in a row to sign in to <%%= t("application_name") %> with your email failed, with a wrong password. To keep your account safe, signing in is paused for a few minutes, a little longer after each new failure.
4
+
5
+ If it was you, wait a few minutes before trying again, or choose a new password:
6
+ <%%= @reset_url %>
7
+
8
+ If it wasn't you, someone may be trying to guess your password: if it's short or you use it elsewhere, choose a new one.
9
+
10
+ The <%%= t("application_name") %> team
@@ -0,0 +1,18 @@
1
+ p Bonjour #{@user},
2
+ p
3
+ | Plusieurs tentatives de connexion à #{t("application_name")}
4
+ avec votre adresse e-mail ont échoué d'affilée, avec un mauvais mot de
5
+ passe. Pour protéger votre compte, la connexion est suspendue quelques
6
+ minutes, un peu plus longtemps après chaque nouvel échec.
7
+ p
8
+ | Si c'était vous, patientez quelques minutes avant de réessayer, ou
9
+ =< link_to "choisissez un nouveau mot de passe",
10
+ @reset_url,
11
+ target: "_blank",
12
+ rel: "noopener"
13
+ | .
14
+ p
15
+ | Si ce n'était pas vous, quelqu'un essaie peut-être de deviner votre mot
16
+ de passe : s'il est court ou que vous l'utilisez ailleurs,
17
+ choisissez-en un nouveau.
18
+ p L'équipe #{t("application_name")}
@@ -0,0 +1,10 @@
1
+ Bonjour <%%= @user %>,
2
+
3
+ Plusieurs tentatives de connexion à <%%= t("application_name") %> avec votre adresse e-mail ont échoué d'affilée, avec un mauvais mot de passe. Pour protéger votre compte, la connexion est suspendue quelques minutes, un peu plus longtemps après chaque nouvel échec.
4
+
5
+ Si c'était vous, patientez quelques minutes avant de réessayer, ou choisissez un nouveau mot de passe :
6
+ <%%= @reset_url %>
7
+
8
+ Si ce n'était pas vous, quelqu'un essaie peut-être de deviner votre mot de passe : s'il est court ou que vous l'utilisez ailleurs, choisissez-en un nouveau.
9
+
10
+ L'équipe <%%= t("application_name") %>
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Trek
6
+ # Restricts the admin (/admin and below, the sign-in page included) to the IP addresses and ranges in
7
+ # ADMIN_ALLOWED_IPS, separated by commas or spaces: "203.0.113.0/24, 2001:db8::/32, 198.51.100.7".
8
+ # From anywhere else, the admin doesn't exist: a 404, as for an unknown URL, rather than a redirect
9
+ # to the sign-in page. Off when ADMIN_ALLOWED_IPS is unset or empty; read when the app starts.
10
+ #
11
+ # The address is `request.remote_ip`: behind a proxy or a CDN, list its ranges in
12
+ # `config.action_dispatch.trusted_proxies`, or every request seems to come from it (docs: proxies).
13
+ #
14
+ # Inserted after ActionDispatch::DebugExceptions (Trek::Engine): after RemoteIp, which works out the
15
+ # address, and inside ShowExceptions, which turns the routing error into the app's 404 page.
16
+ class AdminIpAllowlist
17
+ PATH = %r{\A/admin(/|\z)}
18
+
19
+ def initialize(app, allowed = ENV.fetch("ADMIN_ALLOWED_IPS", nil))
20
+ @app = app
21
+ @ranges = parse(allowed)
22
+ end
23
+
24
+ def call(env)
25
+ if @ranges.any? && env["PATH_INFO"].to_s.match?(PATH) && !allowed?(ActionDispatch::Request.new(env).remote_ip)
26
+ # The same error, and log line, as for a URL no route matches
27
+ raise ActionController::RoutingError, "No route matches [#{env["REQUEST_METHOD"]}] #{env["PATH_INFO"].inspect}"
28
+ end
29
+
30
+ @app.call(env)
31
+ end
32
+
33
+ private
34
+
35
+ def parse(allowed)
36
+ allowed.to_s.split(/[\s,]+/).reject(&:empty?).map do |range|
37
+ IPAddr.new(range)
38
+ rescue IPAddr::Error
39
+ raise ArgumentError, "ADMIN_ALLOWED_IPS: #{range.inspect} is neither an IP address nor a range"
40
+ end
41
+ end
42
+
43
+ def allowed?(remote_ip)
44
+ ip = IPAddr.new(remote_ip.to_s)
45
+ # An IPv4 address may come as IPv6 ("::ffff:203.0.113.5")
46
+ ip = ip.native if ip.ipv4_mapped?
47
+ @ranges.any? { |range| range.family == ip.family && range.include?(ip) }
48
+ rescue IPAddr::Error
49
+ false
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Trek
7
+ # A Content Security Policy violation, as an error for AppSignal (Trek::CspReportsController)
8
+ class CspViolation < StandardError; end
9
+
10
+ # The violations in a Content Security Policy report, as browsers send them to the policy's
11
+ # report-uri: `application/csp-report` (`{"csp-report": {…}}`), or the Reporting API's
12
+ # `application/reports+json` (`[{"type": "csp-violation", "body": {…}}]`).
13
+ #
14
+ # Kept: the app's own pages only (a report can claim any page), and not what browser extensions
15
+ # inject or load, which makes most reports and says nothing about the app. As anyone can forge a
16
+ # report, it must be plausible too: a known directive, and a keyword or a host name blocked.
17
+ #
18
+ # A violation keeps the page's path, without its query, which may carry a token, and the blocked
19
+ # resource's origin.
20
+ module CspReports
21
+ EXTENSION_SCHEMES = %w[chrome-extension moz-extension safari-extension safari-web-extension ms-browser-extension].freeze
22
+
23
+ # The directives a browser reports a violation of
24
+ DIRECTIVES = %w[
25
+ base-uri child-src connect-src default-src font-src form-action frame-ancestors frame-src img-src
26
+ manifest-src media-src object-src require-trusted-types-for script-src script-src-attr
27
+ script-src-elem style-src style-src-attr style-src-elem trusted-types worker-src
28
+ ].freeze
29
+ # What a browser reports as blocked instead of a URL
30
+ KEYWORDS = %w[inline eval wasm-eval data blob trusted-types-policy trusted-types-sink].freeze
31
+ # Schemes of a blocked URL, which keeps its origin
32
+ SCHEMES = %w[http https ws wss].freeze
33
+ HOST_NAME = /\A(?=.{1,253}\z)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\z/i
34
+
35
+ Violation = Struct.new(:directive, :blocked, :page, :disposition, keyword_init: true) do
36
+ def to_s
37
+ "#{directive} blocked #{blocked} on #{page} (#{disposition})"
38
+ end
39
+ end
40
+
41
+ # The violations about pages of `host`
42
+ def self.parse(body, host:)
43
+ reports = JSON.parse(body)
44
+ fields =
45
+ if reports.is_a?(Array)
46
+ reports.select { |report| report.is_a?(Hash) && report["type"] == "csp-violation" }.map { |report| report["body"] }
47
+ elsif reports.is_a?(Hash)
48
+ [reports["csp-report"]]
49
+ end
50
+ Array(fields).select { |field| field.is_a?(Hash) }.filter_map { |field| violation(field, host:) }
51
+ rescue JSON::ParserError
52
+ []
53
+ end
54
+
55
+ def self.violation(fields, host:)
56
+ page = parse_uri(fields["document-uri"] || fields["documentURL"])
57
+ return unless page&.host == host
58
+
59
+ blocked = fields["blocked-uri"] || fields["blockedURL"]
60
+ source = fields["source-file"] || fields["sourceFile"]
61
+ return if [blocked, source].any? { |uri| extension?(uri) }
62
+
63
+ directive = fields["effective-directive"] || fields["effectiveDirective"] || fields["violated-directive"].to_s.split.first
64
+ blocked = origin(blocked)
65
+ return unless DIRECTIVES.include?(directive) && blocked
66
+
67
+ Violation.new(
68
+ directive:,
69
+ blocked:,
70
+ page: page.path.presence || "/",
71
+ disposition: fields["disposition"] || "enforce"
72
+ )
73
+ end
74
+ private_class_method :violation
75
+
76
+ # A URL's origin, or the keyword a browser reports instead ("inline", "eval", "data"…); nil when
77
+ # it's neither
78
+ def self.origin(value)
79
+ uri = parse_uri(value)
80
+ return KEYWORDS.find { |keyword| keyword == (value.to_s.presence || "inline") } unless uri&.host
81
+ return unless SCHEMES.include?(uri.scheme) && uri.host.match?(HOST_NAME)
82
+
83
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
84
+ end
85
+ private_class_method :origin
86
+
87
+ def self.extension?(value)
88
+ EXTENSION_SCHEMES.include?(value.to_s.split(":", 2).first)
89
+ end
90
+ private_class_method :extension?
91
+
92
+ def self.parse_uri(value)
93
+ URI.parse(value.to_s)
94
+ rescue URI::InvalidURIError
95
+ nil
96
+ end
97
+ private_class_method :parse_uri
98
+ end
99
+ end