Compare commits
12 Commits
e39721c7e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdb10d86fb | ||
|
|
37e6e2cc19 | ||
|
|
9648b64043 | ||
|
|
a5eba9a5cd | ||
|
|
afa90303c8 | ||
|
|
df5dbfc46c | ||
|
|
2768104c1e | ||
|
|
2e427a0520 | ||
|
|
556656d090 | ||
|
|
cc93f72f0a | ||
|
|
09e9b32e46 | ||
|
|
7d352654fd |
@@ -1 +1 @@
|
|||||||
4.0.1
|
4.0.3
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||||
|
|
||||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||||
ARG RUBY_VERSION=4.0.1
|
ARG RUBY_VERSION=4.0.3
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||||
|
|||||||
2
Gemfile
2
Gemfile
@@ -1,7 +1,7 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||||
gem "rails", "~> 8.1.2"
|
gem "rails", "~> 8.1.3"
|
||||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||||
gem "propshaft"
|
gem "propshaft"
|
||||||
# Use sqlite3 as the database for Active Record
|
# Use sqlite3 as the database for Active Record
|
||||||
|
|||||||
260
Gemfile.lock
260
Gemfile.lock
@@ -1,31 +1,31 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.16)
|
action_text-trix (2.1.18)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.2)
|
actioncable (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.1.2)
|
actionmailbox (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activejob (= 8.1.2)
|
activejob (= 8.1.3)
|
||||||
activerecord (= 8.1.2)
|
activerecord (= 8.1.3)
|
||||||
activestorage (= 8.1.2)
|
activestorage (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.1.2)
|
actionmailer (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
actionview (= 8.1.2)
|
actionview (= 8.1.3)
|
||||||
activejob (= 8.1.2)
|
activejob (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.1.2)
|
actionpack (8.1.3)
|
||||||
actionview (= 8.1.2)
|
actionview (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
@@ -33,36 +33,36 @@ GEM
|
|||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.1.2)
|
actiontext (8.1.3)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.15)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activerecord (= 8.1.2)
|
activerecord (= 8.1.3)
|
||||||
activestorage (= 8.1.2)
|
activestorage (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.1.2)
|
actionview (8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.1.2)
|
activejob (8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.1.2)
|
activemodel (8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
activerecord (8.1.2)
|
activerecord (8.1.3)
|
||||||
activemodel (= 8.1.2)
|
activemodel (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.1.2)
|
activestorage (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activejob (= 8.1.2)
|
activejob (= 8.1.3)
|
||||||
activerecord (= 8.1.2)
|
activerecord (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.1.2)
|
activesupport (8.1.3)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
@@ -75,19 +75,19 @@ GEM
|
|||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
addressable (2.8.8)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.21)
|
bcrypt (3.1.22)
|
||||||
bcrypt_pbkdf (1.1.2)
|
bcrypt_pbkdf (1.1.2)
|
||||||
bigdecimal (4.0.1)
|
bigdecimal (4.1.2)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.20.1)
|
bootsnap (1.24.1)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.1.2)
|
brakeman (8.0.4)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -102,7 +102,7 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
cbor (0.5.10.1)
|
cbor (0.5.10.2)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
@@ -120,17 +120,17 @@ GEM
|
|||||||
dotenv (3.2.0)
|
dotenv (3.2.0)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (6.0.2)
|
erb (6.0.4)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
ffi (1.17.3-aarch64-linux-gnu)
|
ffi (1.17.4-aarch64-linux-gnu)
|
||||||
ffi (1.17.3-aarch64-linux-musl)
|
ffi (1.17.4-aarch64-linux-musl)
|
||||||
ffi (1.17.3-arm-linux-gnu)
|
ffi (1.17.4-arm-linux-gnu)
|
||||||
ffi (1.17.3-arm-linux-musl)
|
ffi (1.17.4-arm-linux-musl)
|
||||||
ffi (1.17.3-arm64-darwin)
|
ffi (1.17.4-arm64-darwin)
|
||||||
ffi (1.17.3-x86_64-linux-gnu)
|
ffi (1.17.4-x86_64-linux-gnu)
|
||||||
ffi (1.17.3-x86_64-linux-musl)
|
ffi (1.17.4-x86_64-linux-musl)
|
||||||
fugit (1.12.1)
|
fugit (1.12.1)
|
||||||
et-orbi (~> 1.4)
|
et-orbi (~> 1.4)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
@@ -141,12 +141,12 @@ GEM
|
|||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
importmap-rails (2.2.2)
|
importmap-rails (2.2.3)
|
||||||
actionpack (>= 6.0.0)
|
actionpack (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
io-console (0.8.2)
|
io-console (0.8.2)
|
||||||
irb (1.17.0)
|
irb (1.18.0)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
prism (>= 1.3.0)
|
prism (>= 1.3.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
@@ -154,10 +154,10 @@ GEM
|
|||||||
jbuilder (2.14.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.19.0)
|
json (2.19.4)
|
||||||
jwt (3.1.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.10.1)
|
kamal (2.11.0)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
bcrypt_pbkdf (~> 1.0)
|
bcrypt_pbkdf (~> 1.0)
|
||||||
@@ -177,7 +177,7 @@ GEM
|
|||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.25.0)
|
loofah (2.25.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.9.0)
|
||||||
@@ -193,7 +193,7 @@ GEM
|
|||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.27.0)
|
minitest (5.27.0)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
net-imap (0.6.3)
|
net-imap (0.6.4)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -206,68 +206,68 @@ GEM
|
|||||||
net-ssh (>= 5.0.0, < 8.0.0)
|
net-ssh (>= 5.0.0, < 8.0.0)
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.3.0)
|
net-ssh (7.3.2)
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.1-aarch64-linux-gnu)
|
nokogiri (1.19.3-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-aarch64-linux-musl)
|
nokogiri (1.19.3-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-arm-linux-gnu)
|
nokogiri (1.19.3-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-arm-linux-musl)
|
nokogiri (1.19.3-arm-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-arm64-darwin)
|
nokogiri (1.19.3-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-linux-musl)
|
nokogiri (1.19.3-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (4.0.0)
|
openssl (4.0.1)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.28.0)
|
||||||
parser (3.3.10.0)
|
parser (3.3.11.1)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pp (0.6.3)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.7.0)
|
prism (1.9.0)
|
||||||
propshaft (1.3.1)
|
propshaft (1.3.2)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
psych (5.3.1)
|
psych (5.3.1)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (7.0.0)
|
public_suffix (7.0.5)
|
||||||
puma (7.1.0)
|
puma (8.0.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.5)
|
rack (3.2.6)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.2)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.2.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.3.1)
|
rackup (2.3.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.1.2)
|
rails (8.1.3)
|
||||||
actioncable (= 8.1.2)
|
actioncable (= 8.1.3)
|
||||||
actionmailbox (= 8.1.2)
|
actionmailbox (= 8.1.3)
|
||||||
actionmailer (= 8.1.2)
|
actionmailer (= 8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
actiontext (= 8.1.2)
|
actiontext (= 8.1.3)
|
||||||
actionview (= 8.1.2)
|
actionview (= 8.1.3)
|
||||||
activejob (= 8.1.2)
|
activejob (= 8.1.3)
|
||||||
activemodel (= 8.1.2)
|
activemodel (= 8.1.3)
|
||||||
activerecord (= 8.1.2)
|
activerecord (= 8.1.3)
|
||||||
activestorage (= 8.1.2)
|
activestorage (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.2)
|
railties (= 8.1.3)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -275,9 +275,9 @@ GEM
|
|||||||
rails-html-sanitizer (1.7.0)
|
rails-html-sanitizer (1.7.0)
|
||||||
loofah (~> 2.25)
|
loofah (~> 2.25)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
railties (8.1.2)
|
railties (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -285,21 +285,21 @@ GEM
|
|||||||
tsort (>= 0.2)
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
rdoc (7.2.0)
|
rdoc (7.2.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.12.0)
|
||||||
reline (0.6.3)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (3.1.1)
|
rqrcode (3.2.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.0.1)
|
rqrcode_core (2.1.0)
|
||||||
rubocop (1.81.7)
|
rubocop (1.84.2)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
@@ -307,10 +307,10 @@ GEM
|
|||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.49.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.49.0)
|
rubocop-ast (1.49.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.7)
|
prism (~> 1.7)
|
||||||
rubocop-performance (1.26.1)
|
rubocop-performance (1.26.1)
|
||||||
@@ -325,18 +325,19 @@ GEM
|
|||||||
safety_net_attestation (0.5.0)
|
safety_net_attestation (0.5.0)
|
||||||
jwt (>= 2.0, < 4.0)
|
jwt (>= 2.0, < 4.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.39.0)
|
selenium-webdriver (4.43.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (6.2.0)
|
sentry-rails (6.5.0)
|
||||||
railties (>= 5.2.0)
|
railties (>= 5.2.0)
|
||||||
sentry-ruby (~> 6.2.0)
|
sentry-ruby (~> 6.5.0)
|
||||||
sentry-ruby (6.2.0)
|
sentry-ruby (6.5.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
logger
|
||||||
simplecov (0.22.0)
|
simplecov (0.22.0)
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
@@ -352,20 +353,20 @@ GEM
|
|||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
solid_queue (1.2.4)
|
solid_queue (1.4.0)
|
||||||
activejob (>= 7.1)
|
activejob (>= 7.1)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
concurrent-ruby (>= 1.3.1)
|
concurrent-ruby (>= 1.3.1)
|
||||||
fugit (~> 1.11)
|
fugit (~> 1.11)
|
||||||
railties (>= 7.1)
|
railties (>= 7.1)
|
||||||
thor (>= 1.3.1)
|
thor (>= 1.3.1)
|
||||||
sqlite3 (2.9.0-aarch64-linux-gnu)
|
sqlite3 (2.9.3-aarch64-linux-gnu)
|
||||||
sqlite3 (2.9.0-aarch64-linux-musl)
|
sqlite3 (2.9.3-aarch64-linux-musl)
|
||||||
sqlite3 (2.9.0-arm-linux-gnu)
|
sqlite3 (2.9.3-arm-linux-gnu)
|
||||||
sqlite3 (2.9.0-arm-linux-musl)
|
sqlite3 (2.9.3-arm-linux-musl)
|
||||||
sqlite3 (2.9.0-arm64-darwin)
|
sqlite3 (2.9.3-arm64-darwin)
|
||||||
sqlite3 (2.9.0-x86_64-linux-gnu)
|
sqlite3 (2.9.3-x86_64-linux-gnu)
|
||||||
sqlite3 (2.9.0-x86_64-linux-musl)
|
sqlite3 (2.9.3-x86_64-linux-musl)
|
||||||
sshkit (1.25.0)
|
sshkit (1.25.0)
|
||||||
base64
|
base64
|
||||||
logger
|
logger
|
||||||
@@ -373,10 +374,10 @@ GEM
|
|||||||
net-sftp (>= 2.1.2)
|
net-sftp (>= 2.1.2)
|
||||||
net-ssh (>= 2.8.0)
|
net-ssh (>= 2.8.0)
|
||||||
ostruct
|
ostruct
|
||||||
standard (1.52.0)
|
standard (1.54.0)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.0)
|
lint_roller (~> 1.0)
|
||||||
rubocop (~> 1.81.7)
|
rubocop (~> 1.84.0)
|
||||||
standard-custom (~> 1.0.0)
|
standard-custom (~> 1.0.0)
|
||||||
standard-performance (~> 1.8)
|
standard-performance (~> 1.8)
|
||||||
standard-custom (1.0.2)
|
standard-custom (1.0.2)
|
||||||
@@ -391,24 +392,24 @@ GEM
|
|||||||
tailwindcss-rails (4.4.0)
|
tailwindcss-rails (4.4.0)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 4.0)
|
tailwindcss-ruby (~> 4.0)
|
||||||
tailwindcss-ruby (4.1.18)
|
tailwindcss-ruby (4.2.4)
|
||||||
tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
|
tailwindcss-ruby (4.2.4-aarch64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.18-aarch64-linux-musl)
|
tailwindcss-ruby (4.2.4-aarch64-linux-musl)
|
||||||
tailwindcss-ruby (4.1.18-arm64-darwin)
|
tailwindcss-ruby (4.2.4-arm64-darwin)
|
||||||
tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
|
tailwindcss-ruby (4.2.4-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.18-x86_64-linux-musl)
|
tailwindcss-ruby (4.2.4-x86_64-linux-musl)
|
||||||
thor (1.5.0)
|
thor (1.5.0)
|
||||||
thruster (0.1.17)
|
thruster (0.1.20)
|
||||||
thruster (0.1.17-aarch64-linux)
|
thruster (0.1.20-aarch64-linux)
|
||||||
thruster (0.1.17-arm64-darwin)
|
thruster (0.1.20-arm64-darwin)
|
||||||
thruster (0.1.17-x86_64-linux)
|
thruster (0.1.20-x86_64-linux)
|
||||||
timeout (0.6.0)
|
timeout (0.6.1)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.20)
|
turbo-rails (2.0.23)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
@@ -418,11 +419,10 @@ GEM
|
|||||||
unicode-emoji (4.2.0)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-console (4.2.1)
|
web-console (4.3.0)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 8.0.0)
|
||||||
activemodel (>= 6.0.0)
|
|
||||||
bindex (>= 0.4.0)
|
bindex (>= 0.4.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 8.0.0)
|
||||||
webauthn (3.4.3)
|
webauthn (3.4.3)
|
||||||
android_key_attestation (~> 0.3.0)
|
android_key_attestation (~> 0.3.0)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
@@ -469,7 +469,7 @@ DEPENDENCIES
|
|||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 7.0)
|
public_suffix (~> 7.0)
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.2)
|
rails (~> 8.1.3)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
@@ -490,4 +490,4 @@ DEPENDENCIES
|
|||||||
webauthn (~> 3.0)
|
webauthn (~> 3.0)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
4.0.3
|
4.0.6
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class ApiKeysController < ApplicationController
|
|||||||
@api_key = Current.session.user.api_keys.build(api_key_params)
|
@api_key = Current.session.user.api_keys.build(api_key_params)
|
||||||
|
|
||||||
if @api_key.save
|
if @api_key.save
|
||||||
|
SecurityMailer.api_key_created(Current.session.user, name: @api_key.name, **security_event_context).deliver_later
|
||||||
flash[:api_key_token] = @api_key.plaintext_token
|
flash[:api_key_token] = @api_key.plaintext_token
|
||||||
redirect_to api_key_path(@api_key)
|
redirect_to api_key_path(@api_key)
|
||||||
else
|
else
|
||||||
@@ -31,6 +32,7 @@ class ApiKeysController < ApplicationController
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@api_key.revoke!
|
@api_key.revoke!
|
||||||
|
SecurityMailer.api_key_revoked(@api_key.user, name: @api_key.name, **security_event_context).deliver_later
|
||||||
redirect_to api_keys_path, notice: "API key revoked."
|
redirect_to api_keys_path, notice: "API key revoked."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class ApplicationController < ActionController::Base
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def security_event_context
|
||||||
|
{ip: request.remote_ip, user_agent: request.user_agent, occurred_at: Time.current}
|
||||||
|
end
|
||||||
|
|
||||||
# Remove a query parameter from a URL using proper URI parsing
|
# Remove a query parameter from a URL using proper URI parsing
|
||||||
# More robust than regex - handles URL encoding, edge cases, etc.
|
# More robust than regex - handles URL encoding, edge cases, etc.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -73,7 +73,15 @@ module Authentication
|
|||||||
# Set domain for cross-subdomain authentication if we can extract it
|
# Set domain for cross-subdomain authentication if we can extract it
|
||||||
cookie_options[:domain] = domain if domain.present?
|
cookie_options[:domain] = domain if domain.present?
|
||||||
|
|
||||||
|
# When "Remember me" is off, issue a browser-session cookie (no Expires)
|
||||||
|
# so closing the browser signs the user out — especially important on
|
||||||
|
# shared devices. The server Session#expires_at still enforces the
|
||||||
|
# 24h / 30d window regardless.
|
||||||
|
if remember_me
|
||||||
cookies.signed.permanent[:session_id] = cookie_options
|
cookies.signed.permanent[:session_id] = cookie_options
|
||||||
|
else
|
||||||
|
cookies.signed[:session_id] = cookie_options
|
||||||
|
end
|
||||||
|
|
||||||
# Create a one-time token for immediate forward auth after authentication
|
# Create a one-time token for immediate forward auth after authentication
|
||||||
# This solves the race condition where browser hasn't processed cookie yet
|
# This solves the race condition where browser hasn't processed cookie yet
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class PasswordsController < ApplicationController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
if @user.update(params.permit(:password, :password_confirmation))
|
if @user.update(params.permit(:password, :password_confirmation))
|
||||||
|
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
||||||
@user.sessions.destroy_all
|
@user.sessions.destroy_all
|
||||||
redirect_to signin_path, notice: "Password has been reset."
|
redirect_to signin_path, notice: "Password has been reset."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ProfilesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
if @user.update(password_params)
|
if @user.update(password_params)
|
||||||
|
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
||||||
redirect_to profile_path, notice: "Password updated successfully."
|
redirect_to profile_path, notice: "Password updated successfully."
|
||||||
else
|
else
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
@@ -27,7 +28,15 @@ class ProfilesController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
old_email = @user.email_address
|
||||||
if @user.update(email_params)
|
if @user.update(email_params)
|
||||||
|
new_email = @user.email_address
|
||||||
|
if old_email != new_email
|
||||||
|
context = security_event_context
|
||||||
|
[old_email, new_email].uniq.each do |recipient|
|
||||||
|
SecurityMailer.email_address_changed(@user, recipient: recipient, old_email: old_email, new_email: new_email, **context).deliver_later
|
||||||
|
end
|
||||||
|
end
|
||||||
redirect_to profile_path, notice: "Email updated successfully."
|
redirect_to profile_path, notice: "Email updated successfully."
|
||||||
else
|
else
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class TotpController < ApplicationController
|
|||||||
# Generate new backup codes and store BCrypt hashes
|
# Generate new backup codes and store BCrypt hashes
|
||||||
plain_codes = @user.send(:generate_backup_codes)
|
plain_codes = @user.send(:generate_backup_codes)
|
||||||
@user.save!
|
@user.save!
|
||||||
|
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
|
||||||
|
|
||||||
# Store plain codes temporarily in session for display
|
# Store plain codes temporarily in session for display
|
||||||
session[:temp_backup_codes] = plain_codes
|
session[:temp_backup_codes] = plain_codes
|
||||||
@@ -136,6 +137,7 @@ class TotpController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
@user.disable_totp!
|
@user.disable_totp!
|
||||||
|
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
|
||||||
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ class WebauthnController < ApplicationController
|
|||||||
backup_state: backup_state
|
backup_state: backup_state
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SecurityMailer.passkey_added(user, nickname: @webauthn_credential.nickname, **security_event_context).deliver_later
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Passkey '#{nickname}' registered successfully",
|
message: "Passkey '#{nickname}' registered successfully",
|
||||||
@@ -109,8 +111,11 @@ class WebauthnController < ApplicationController
|
|||||||
# Remove a passkey
|
# Remove a passkey
|
||||||
def destroy
|
def destroy
|
||||||
nickname = @webauthn_credential.nickname
|
nickname = @webauthn_credential.nickname
|
||||||
|
user = @webauthn_credential.user
|
||||||
@webauthn_credential.destroy
|
@webauthn_credential.destroy
|
||||||
|
|
||||||
|
SecurityMailer.passkey_removed(user, nickname: nickname, **security_event_context).deliver_later
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html {
|
format.html {
|
||||||
redirect_to profile_path,
|
redirect_to profile_path,
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ module ApplicationHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def oidc_env_lines(application, client_secret: nil)
|
||||||
|
lines = ["OIDC_CLIENT_ID=#{application.client_id}"]
|
||||||
|
lines << if client_secret
|
||||||
|
"OIDC_CLIENT_SECRET=#{client_secret}"
|
||||||
|
elsif application.public_client?
|
||||||
|
"OIDC_CLIENT_SECRET="
|
||||||
|
else
|
||||||
|
"OIDC_CLIENT_SECRET=<your-client-secret>"
|
||||||
|
end
|
||||||
|
lines << "OIDC_DISCOVERY_URL=#{OidcJwtService.issuer_url}"
|
||||||
|
lines << "OIDC_PROVIDER_NAME='Clinch'"
|
||||||
|
lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? 'true' : 'false'}"
|
||||||
|
lines
|
||||||
|
end
|
||||||
|
|
||||||
def border_class_for(type)
|
def border_class_for(type)
|
||||||
case type.to_s
|
case type.to_s
|
||||||
when "notice" then "border-green-200 dark:border-green-700"
|
when "notice" then "border-green-200 dark:border-green-700"
|
||||||
|
|||||||
59
app/mailers/security_mailer.rb
Normal file
59
app/mailers/security_mailer.rb
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
class SecurityMailer < ApplicationMailer
|
||||||
|
SUBJECT_PREFIX = "[Clinch security alert] ".freeze
|
||||||
|
|
||||||
|
def password_changed(user, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}Your password was changed", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def totp_disabled(user, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}Two-factor authentication was disabled", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def backup_codes_regenerated(user, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}Two-factor backup codes were regenerated", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def passkey_added(user, nickname:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@nickname = nickname
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}A passkey was added to your account", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def passkey_removed(user, nickname:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@nickname = nickname
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}A passkey was removed from your account", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_key_created(user, name:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@api_key_name = name
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}An API key was created on your account", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_key_revoked(user, name:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@api_key_name = name
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}An API key was revoked on your account", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def email_address_changed(user, recipient:, old_email:, new_email:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@recipient = recipient
|
||||||
|
@old_email = old_email
|
||||||
|
@new_email = new_email
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}Your account email address was changed", to: recipient
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@user = user
|
||||||
|
@ip = ip
|
||||||
|
@user_agent = user_agent
|
||||||
|
@occurred_at = occurred_at
|
||||||
|
end
|
||||||
|
end
|
||||||
73
app/models/svg_scrubber.rb
Normal file
73
app/models/svg_scrubber.rb
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Loofah scrubber that strips dangerous content from SVG files
|
||||||
|
# while preserving safe SVG elements and attributes for icon display.
|
||||||
|
class SvgScrubber < Loofah::Scrubber
|
||||||
|
ALLOWED_ELEMENTS = %w[
|
||||||
|
svg g defs use symbol
|
||||||
|
circle ellipse line path polygon polyline rect
|
||||||
|
text tspan textPath
|
||||||
|
clipPath mask pattern
|
||||||
|
linearGradient radialGradient stop
|
||||||
|
filter feBlend feColorMatrix feComponentTransfer feComposite
|
||||||
|
feConvolveMatrix feDiffuseLighting feDisplacementMap feFlood
|
||||||
|
feGaussianBlur feImage feMerge feMergeNode feMorphology
|
||||||
|
feOffset feSpecularLighting feTile feTurbulence
|
||||||
|
title desc metadata
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
ALLOWED_ATTRIBUTES = %w[
|
||||||
|
id class style
|
||||||
|
x y x1 y1 x2 y2 cx cy r rx ry
|
||||||
|
width height viewBox preserveAspectRatio
|
||||||
|
d points
|
||||||
|
fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray
|
||||||
|
opacity fill-opacity stroke-opacity
|
||||||
|
transform translate rotate scale
|
||||||
|
font-family font-size font-weight text-anchor
|
||||||
|
clip-path mask filter
|
||||||
|
gradientUnits gradientTransform spreadMethod
|
||||||
|
offset stop-color stop-opacity
|
||||||
|
dx dy textLength lengthAdjust
|
||||||
|
xmlns xmlns:xlink
|
||||||
|
color display visibility overflow
|
||||||
|
fill-rule clip-rule
|
||||||
|
marker-start marker-mid marker-end
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
# Loofah hands attribute names back in their source case (e.g. "viewBox").
|
||||||
|
# Compare against a downcased copy so SVG-spec camelCase attributes aren't
|
||||||
|
# stripped from legitimate icons.
|
||||||
|
ALLOWED_ATTRIBUTES_LOOKUP = ALLOWED_ATTRIBUTES.map(&:downcase).to_set.freeze
|
||||||
|
|
||||||
|
# Event handler attributes that must always be removed
|
||||||
|
EVENT_HANDLER_PATTERN = /\Aon/i
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@direction = :top_down
|
||||||
|
end
|
||||||
|
|
||||||
|
def scrub(node)
|
||||||
|
return CONTINUE if node.text? || node.cdata?
|
||||||
|
|
||||||
|
if node.element?
|
||||||
|
if ALLOWED_ELEMENTS.include?(node.name)
|
||||||
|
# Remove disallowed and event handler attributes
|
||||||
|
node.attribute_nodes.each do |attr|
|
||||||
|
attr.remove unless safe_attribute?(attr)
|
||||||
|
end
|
||||||
|
return CONTINUE
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
node.remove
|
||||||
|
STOP
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def safe_attribute?(attr)
|
||||||
|
name = attr.name.downcase
|
||||||
|
return false if name.match?(EVENT_HANDLER_PATTERN)
|
||||||
|
return false if attr.value&.match?(/javascript:|data:/i)
|
||||||
|
ALLOWED_ATTRIBUTES_LOOKUP.include?(name)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -25,6 +25,23 @@
|
|||||||
Public clients do not have a client secret. PKCE is required.
|
Public clients do not have a client secret. PKCE is required.
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% env_lines = oidc_env_lines(@application, client_secret: flash[:client_secret]) %>
|
||||||
|
|
||||||
|
<div class="mt-4" data-controller="clipboard">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Environment variables (copy & paste):</span>
|
||||||
|
<button type="button"
|
||||||
|
data-action="clipboard#copy"
|
||||||
|
class="text-xs font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 underline">
|
||||||
|
<span data-clipboard-target="label">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea data-clipboard-target="source"
|
||||||
|
readonly
|
||||||
|
rows="<%= env_lines.length %>"
|
||||||
|
class="block w-full bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-yellow-500"><%= env_lines.join("\n") %></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -157,6 +174,30 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<div>
|
||||||
|
<details class="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-700 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Environment variables
|
||||||
|
</summary>
|
||||||
|
<div class="px-4 py-3" data-controller="clipboard">
|
||||||
|
<% env_lines = oidc_env_lines(@application) %>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<%= @application.confidential_client? ? "Replace <your-client-secret> with your saved secret." : "Public client — no secret required." %>
|
||||||
|
</span>
|
||||||
|
<button type="button"
|
||||||
|
data-action="clipboard#copy"
|
||||||
|
class="text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 underline">
|
||||||
|
<span data-clipboard-target="label">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea data-clipboard-target="source"
|
||||||
|
readonly
|
||||||
|
rows="<%= env_lines.length %>"
|
||||||
|
class="block w-full bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-gray-500"><%= env_lines.join("\n") %></textarea>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<p>
|
<p>
|
||||||
You can reset your password on
|
You can reset your password on
|
||||||
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
|
<%= link_to "this password reset page", edit_password_url(@user.generate_token_for(:password_reset)) %>.
|
||||||
|
|
||||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
This link will expire in 1 hour.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
You can reset your password on
|
You can reset your password on
|
||||||
<%= edit_password_url(@user.password_reset_token) %>
|
<%= edit_password_url(@user.generate_token_for(:password_reset)) %>
|
||||||
|
|
||||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
This link will expire in 1 hour.
|
||||||
|
|||||||
11
app/views/security_mailer/_event_metadata.html.erb
Normal file
11
app/views/security_mailer/_event_metadata.html.erb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<hr>
|
||||||
|
<p>
|
||||||
|
This action was recorded at <strong><%= @occurred_at.to_fs(:long) %></strong>
|
||||||
|
from IP <strong><%= @ip %></strong>
|
||||||
|
using <strong><%= @user_agent.presence || "an unknown client" %></strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you did <strong>not</strong> perform this action, reset your password
|
||||||
|
immediately and contact your administrator.
|
||||||
|
</p>
|
||||||
7
app/views/security_mailer/_event_metadata.text.erb
Normal file
7
app/views/security_mailer/_event_metadata.text.erb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
This action was recorded at <%= @occurred_at.to_fs(:long) %>
|
||||||
|
from IP <%= @ip %>
|
||||||
|
using <%= @user_agent.presence || "an unknown client" %>.
|
||||||
|
|
||||||
|
If you did not perform this action, reset your password immediately
|
||||||
|
and contact your administrator.
|
||||||
8
app/views/security_mailer/api_key_created.html.erb
Normal file
8
app/views/security_mailer/api_key_created.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A new API key (<strong><%= @api_key_name %></strong>) was just created
|
||||||
|
on your Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/api_key_created.text.erb
Normal file
6
app/views/security_mailer/api_key_created.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A new API key ("<%= @api_key_name %>") was just created on your Clinch
|
||||||
|
account (<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/api_key_revoked.html.erb
Normal file
8
app/views/security_mailer/api_key_revoked.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The API key <strong><%= @api_key_name %></strong> was just revoked
|
||||||
|
on your Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/api_key_revoked.text.erb
Normal file
6
app/views/security_mailer/api_key_revoked.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
The API key "<%= @api_key_name %>" was just revoked on your Clinch
|
||||||
|
account (<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A new set of two-factor backup codes was generated on your Clinch
|
||||||
|
account (<strong><%= @user.email_address %></strong>).
|
||||||
|
Any previous backup codes are now invalid.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A new set of two-factor backup codes was generated on your Clinch account
|
||||||
|
(<%= @user.email_address %>). Any previous backup codes are now invalid.
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
22
app/views/security_mailer/email_address_changed.html.erb
Normal file
22
app/views/security_mailer/email_address_changed.html.erb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<% if @recipient == @new_email %>
|
||||||
|
<p>
|
||||||
|
The email address on your Clinch account is now
|
||||||
|
<strong><%= @new_email %></strong>.
|
||||||
|
It was previously <strong><%= @old_email %></strong>.
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<p>
|
||||||
|
The email address on your Clinch account was changed away from this
|
||||||
|
address (<strong><%= @old_email %></strong>) to
|
||||||
|
<strong><%= @new_email %></strong>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If this was <strong>not</strong> you, contact your administrator
|
||||||
|
immediately — whoever made the change can now receive password
|
||||||
|
reset emails for the account.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
14
app/views/security_mailer/email_address_changed.text.erb
Normal file
14
app/views/security_mailer/email_address_changed.text.erb
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
<% if @recipient == @new_email %>
|
||||||
|
The email address on your Clinch account is now <%= @new_email %>.
|
||||||
|
It was previously <%= @old_email %>.
|
||||||
|
<% else %>
|
||||||
|
The email address on your Clinch account was changed away from this
|
||||||
|
address (<%= @old_email %>) to <%= @new_email %>.
|
||||||
|
|
||||||
|
If this was not you, contact your administrator immediately — whoever
|
||||||
|
made the change can now receive password reset emails for the account.
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/passkey_added.html.erb
Normal file
8
app/views/security_mailer/passkey_added.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A new passkey (<strong><%= @nickname %></strong>) was just added to your
|
||||||
|
Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/passkey_added.text.erb
Normal file
6
app/views/security_mailer/passkey_added.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A new passkey ("<%= @nickname %>") was just added to your Clinch account
|
||||||
|
(<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/passkey_removed.html.erb
Normal file
8
app/views/security_mailer/passkey_removed.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A passkey (<strong><%= @nickname %></strong>) was just removed from your
|
||||||
|
Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/passkey_removed.text.erb
Normal file
6
app/views/security_mailer/passkey_removed.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A passkey ("<%= @nickname %>") was just removed from your Clinch account
|
||||||
|
(<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/password_changed.html.erb
Normal file
8
app/views/security_mailer/password_changed.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The password on your Clinch account
|
||||||
|
(<strong><%= @user.email_address %></strong>) was just changed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
5
app/views/security_mailer/password_changed.text.erb
Normal file
5
app/views/security_mailer/password_changed.text.erb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
The password on your Clinch account (<%= @user.email_address %>) was just changed.
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/totp_disabled.html.erb
Normal file
8
app/views/security_mailer/totp_disabled.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Two-factor authentication was just <strong>disabled</strong> on your
|
||||||
|
Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/totp_disabled.text.erb
Normal file
6
app/views/security_mailer/totp_disabled.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
Two-factor authentication was just disabled on your Clinch account
|
||||||
|
(<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
@@ -38,10 +38,6 @@ env:
|
|||||||
secret:
|
secret:
|
||||||
- RAILS_MASTER_KEY
|
- RAILS_MASTER_KEY
|
||||||
clear:
|
clear:
|
||||||
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
|
|
||||||
# When you start using multiple servers, you should split out job processing to a dedicated machine.
|
|
||||||
SOLID_QUEUE_IN_PUMA: true
|
|
||||||
|
|
||||||
# Set number of processes dedicated to Solid Queue (default: 1)
|
# Set number of processes dedicated to Solid Queue (default: 1)
|
||||||
# JOB_CONCURRENCY: 3
|
# JOB_CONCURRENCY: 3
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Clinch
|
module Clinch
|
||||||
VERSION = "0.9.0"
|
VERSION = "0.10.1"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ port ENV.fetch("PORT", 3000)
|
|||||||
# Allow puma to be restarted by `bin/rails restart` command.
|
# Allow puma to be restarted by `bin/rails restart` command.
|
||||||
plugin :tmp_restart
|
plugin :tmp_restart
|
||||||
|
|
||||||
# Solid Queue plugin removed - now using async processor
|
# Run the Solid Queue supervisor inside Puma. Clinch ships as a single
|
||||||
|
# container, so the web process is always the worker too.
|
||||||
|
plugin :solid_queue if ENV.fetch("RAILS_ENV", "development") == "production"
|
||||||
|
|
||||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||||
# In other environments, only set the PID file if requested.
|
# In other environments, only set the PID file if requested.
|
||||||
|
|||||||
@@ -30,4 +30,22 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_redirected_to signin_path
|
assert_redirected_to signin_path
|
||||||
assert_empty cookies[:session_id]
|
assert_empty cookies[:session_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "session cookie has no Expires attribute when remember_me is off" do
|
||||||
|
post session_path, params: {email_address: @user.email_address, password: "password", remember_me: "0"}
|
||||||
|
|
||||||
|
set_cookie = Array(response.headers["Set-Cookie"]).find { |c| c.start_with?("session_id=") }
|
||||||
|
assert set_cookie, "session_id cookie should be set"
|
||||||
|
refute_match(/expires=/i, set_cookie,
|
||||||
|
"without Remember me, the session cookie must be a browser-session cookie (no Expires)")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "session cookie has long-lived Expires attribute when remember_me is on" do
|
||||||
|
post session_path, params: {email_address: @user.email_address, password: "password", remember_me: "1"}
|
||||||
|
|
||||||
|
set_cookie = Array(response.headers["Set-Cookie"]).find { |c| c.start_with?("session_id=") }
|
||||||
|
assert set_cookie, "session_id cookie should be set"
|
||||||
|
assert_match(/expires=/i, set_cookie,
|
||||||
|
"with Remember me, the cookie should have an Expires attribute")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
111
test/mailers/security_mailer_test.rb
Normal file
111
test/mailers/security_mailer_test.rb
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class SecurityMailerTest < ActionMailer::TestCase
|
||||||
|
CONTEXT = {
|
||||||
|
ip: "203.0.113.42",
|
||||||
|
user_agent: "Mozilla/5.0 (TestBrowser)",
|
||||||
|
occurred_at: Time.utc(2026, 5, 2, 13, 37)
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def setup
|
||||||
|
@user = User.create!(email_address: "security_mailer_test@example.com", password: "password123")
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
@user.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
test "password_changed names the user and includes request metadata" do
|
||||||
|
email = SecurityMailer.password_changed(@user, **CONTEXT)
|
||||||
|
|
||||||
|
assert_equal [@user.email_address], email.to
|
||||||
|
assert_match(/password was changed/i, email.subject)
|
||||||
|
assert_bodies_contain email, @user.email_address
|
||||||
|
assert_bodies_contain email, "203.0.113.42"
|
||||||
|
assert_bodies_contain email, "TestBrowser"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "totp_disabled describes the change" do
|
||||||
|
email = SecurityMailer.totp_disabled(@user, **CONTEXT)
|
||||||
|
|
||||||
|
assert_equal [@user.email_address], email.to
|
||||||
|
assert_match(/two-factor.*disabled/i, email.subject)
|
||||||
|
assert_bodies_contain email, "203.0.113.42"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "backup_codes_regenerated mentions previous codes are invalid" do
|
||||||
|
email = SecurityMailer.backup_codes_regenerated(@user, **CONTEXT)
|
||||||
|
|
||||||
|
assert_match(/backup codes/i, email.subject)
|
||||||
|
assert_bodies_match email, /previous backup codes are now invalid/i
|
||||||
|
end
|
||||||
|
|
||||||
|
test "passkey_added includes the nickname" do
|
||||||
|
email = SecurityMailer.passkey_added(@user, nickname: "Yubikey-5", **CONTEXT)
|
||||||
|
|
||||||
|
assert_match(/passkey.*added/i, email.subject)
|
||||||
|
assert_bodies_contain email, "Yubikey-5"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "passkey_removed includes the nickname" do
|
||||||
|
email = SecurityMailer.passkey_removed(@user, nickname: "Old MacBook", **CONTEXT)
|
||||||
|
|
||||||
|
assert_match(/passkey.*removed/i, email.subject)
|
||||||
|
assert_bodies_contain email, "Old MacBook"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "api_key_created includes the key name" do
|
||||||
|
email = SecurityMailer.api_key_created(@user, name: "CI bot", **CONTEXT)
|
||||||
|
|
||||||
|
assert_match(/api key.*created/i, email.subject)
|
||||||
|
assert_bodies_contain email, "CI bot"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "api_key_revoked includes the key name" do
|
||||||
|
email = SecurityMailer.api_key_revoked(@user, name: "Old token", **CONTEXT)
|
||||||
|
|
||||||
|
assert_match(/api key.*revoked/i, email.subject)
|
||||||
|
assert_bodies_contain email, "Old token"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "email_address_changed sent to new address confirms the new value" do
|
||||||
|
email = SecurityMailer.email_address_changed(@user,
|
||||||
|
recipient: "new@example.com",
|
||||||
|
old_email: "old@example.com",
|
||||||
|
new_email: "new@example.com",
|
||||||
|
**CONTEXT)
|
||||||
|
|
||||||
|
assert_equal ["new@example.com"], email.to
|
||||||
|
assert_bodies_contain email, "new@example.com"
|
||||||
|
assert_bodies_contain email, "old@example.com"
|
||||||
|
assert_bodies_no_match email, /reset emails for the account/
|
||||||
|
end
|
||||||
|
|
||||||
|
test "email_address_changed sent to old address warns about reset emails" do
|
||||||
|
email = SecurityMailer.email_address_changed(@user,
|
||||||
|
recipient: "old@example.com",
|
||||||
|
old_email: "old@example.com",
|
||||||
|
new_email: "new@example.com",
|
||||||
|
**CONTEXT)
|
||||||
|
|
||||||
|
assert_equal ["old@example.com"], email.to
|
||||||
|
assert_bodies_match email, /reset emails for the account/
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def assert_bodies_contain(email, fragment)
|
||||||
|
assert_match fragment, email.text_part.body.to_s, "expected text body to contain #{fragment.inspect}"
|
||||||
|
assert_match fragment, email.html_part.body.to_s, "expected html body to contain #{fragment.inspect}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_bodies_match(email, regex)
|
||||||
|
assert_match regex, email.text_part.body.to_s
|
||||||
|
assert_match regex, email.html_part.body.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_bodies_no_match(email, regex)
|
||||||
|
refute_match regex, email.text_part.body.to_s
|
||||||
|
refute_match regex, email.html_part.body.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
49
test/models/svg_scrubber_test.rb
Normal file
49
test/models/svg_scrubber_test.rb
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class SvgScrubberTest < ActiveSupport::TestCase
|
||||||
|
def scrub(svg)
|
||||||
|
Loofah.xml_document(svg).scrub!(SvgScrubber.new).to_xml
|
||||||
|
end
|
||||||
|
|
||||||
|
test "strips embedded script elements" do
|
||||||
|
svg = %(<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><path d="M0 0"/></svg>)
|
||||||
|
|
||||||
|
cleaned = scrub(svg)
|
||||||
|
|
||||||
|
refute_match(/<script/i, cleaned)
|
||||||
|
refute_match(/alert/i, cleaned)
|
||||||
|
assert_match(/<path/, cleaned)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "strips on* event handler attributes while preserving the element" do
|
||||||
|
svg = %(<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><circle cx="5" cy="5" r="3" onclick="steal()"/></svg>)
|
||||||
|
|
||||||
|
cleaned = scrub(svg)
|
||||||
|
|
||||||
|
refute_match(/onload/i, cleaned)
|
||||||
|
refute_match(/onclick/i, cleaned)
|
||||||
|
refute_match(/alert|steal/, cleaned)
|
||||||
|
assert_match(/<svg/, cleaned)
|
||||||
|
assert_match(/<circle/, cleaned)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "strips attribute values that point at javascript: or data: URIs" do
|
||||||
|
svg = %(<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)"><path d="M0 0" fill="data:text/html,evil"/></a></svg>)
|
||||||
|
|
||||||
|
cleaned = scrub(svg)
|
||||||
|
|
||||||
|
refute_match(/javascript:/i, cleaned)
|
||||||
|
refute_match(/data:text\/html/i, cleaned)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "preserves a benign icon unchanged in shape" do
|
||||||
|
svg = %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2 L22 22 L2 22 Z" fill="#000"/></svg>)
|
||||||
|
|
||||||
|
cleaned = scrub(svg)
|
||||||
|
|
||||||
|
assert_match(/<svg/, cleaned)
|
||||||
|
assert_match(/<path/, cleaned)
|
||||||
|
assert_match(/M12 2 L22 22 L2 22 Z/, cleaned)
|
||||||
|
assert_match(/viewBox="0 0 24 24"/, cleaned)
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user