12 Commits

Author SHA1 Message Date
Dan Milne
bdb10d86fb Show OIDC env vars on application show page under a toggle
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / scan_container (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / system-test (push) Waiting to run
Previously the copy-pasteable env-var block only appeared right after
creating an app or regenerating credentials. Operators had no easy way
back to it, so they had to reconstruct OIDC_DISCOVERY_URL etc. from
memory.

Adds a collapsed <details> disclosure inside the OIDC Configuration
card with the same env vars (placeholder for the secret, which can't
be re-shown). Extracts the env-line construction into an
oidc_env_lines helper so the flash panel and the persistent display
share one source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:19:14 +10:00
Dan Milne
37e6e2cc19 Show copy-pasteable OIDC env vars after creating an app
Surfaces OIDC_CLIENT_ID/SECRET, discovery URL, provider name, and PKCE
flag in a single textarea on the credentials flash so the client config
can be dropped straight into a consuming app's .env file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:30:30 +10:00
Dan Milne
9648b64043 Bump version to 0.10.1
Patch release covering the Ruby 4.0.3, Rails 8.1.3, and transitive
gem updates landed since 0.10.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:09:27 +10:00
Dan Milne
a5eba9a5cd Update transitive gems
Routine bundle update on the Ruby 4.0.3 install. No app code changes;
test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:09:20 +10:00
Dan Milne
afa90303c8 Bump Rails from 8.1.2 to 8.1.3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:06:22 +10:00
Dan Milne
df5dbfc46c Bump Ruby from 4.0.1 to 4.0.3
Patch release within 4.0.x — security and bug fixes only, no source
changes required. Updated both .ruby-version and the Dockerfile ARG
they're explicitly told to keep in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:06:22 +10:00
Dan Milne
2768104c1e Bump version to 0.10.0
Substantial scope since 0.9.0: API keys for forward auth, SecurityMailer
alerts on 8 account-security events, dark mode, Remember-me with proper
browser-session cookie semantics, SvgScrubber for icon XSS, OIDC
auth-code replay revocation, forward-auth caching + rate limiting, and
fixes for broken invitation / password-reset emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:02:40 +10:00
Dan Milne
2e427a0520 Add SvgScrubber to strip XSS payloads from uploaded app icons
Application#sanitize_svg_icon already runs a Loofah scrubber on every
icon upload, but the scrubber class itself was never tracked. Land it
along with tests covering the four shapes that matter:

- <script> elements stripped entirely
- on* event handlers (onload, onclick, …) removed but the carrying
  element preserved
- attribute values pointing at javascript:/data: URIs rejected
- benign icons round-trip unchanged

Writing the benign-icon test caught a real bug: the attribute allowlist
holds canonical SVG case (viewBox, preserveAspectRatio, gradientUnits,
…) but safe_attribute? downcases the incoming name before comparing,
so legitimate icons were silently losing those attributes on upload.
Fix by comparing against a precomputed lowercase lookup set; the
constant stays readable as canonical SVG case for documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:57:22 +10:00
Dan Milne
556656d090 Drop Remember-me cookie's Expires when the box is unchecked
Without Remember-me the session cookie was still being written via
`cookies.signed.permanent`, so it survived browser restart on shared
devices — surprising for a user who explicitly opted out of Remember-me.
Issue a browser-session cookie (no Expires) when remember_me is off;
the server-side Session#expires_at still bounds the 24h / 30d window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:54:09 +10:00
Dan Milne
cc93f72f0a Notify users out-of-band when security settings change
Previously only TOTP-enabled triggered an email. Every other
security-relevant change — password change, TOTP disable, passkey
add/remove, API key create/revoke, email address change, backup-code
regeneration — happened silently, so an attacker on a stolen session
could quietly drop 2FA or hijack the email with no signal to the
account holder.

Add SecurityMailer with one method per event. Each email carries the
request IP, user-agent, and timestamp so the user can spot unfamiliar
activity. Email-address changes notify both the old and new addresses
with directional language; the old-address copy explicitly warns that
whoever made the change can now receive password reset emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:52:12 +10:00
Dan Milne
09e9b32e46 Run SolidQueue supervisor inside Puma in production
Production switched the queue adapter back to :solid_queue but the Puma
plugin had been removed, so jobs (e.g. invitation resend) enqueued fine
but never ran. Clinch ships as a single container, so always start the
supervisor in production rather than gating on SOLID_QUEUE_IN_PUMA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:51:37 +10:00
Dan Milne
7d352654fd Fix broken password reset email templates
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Both templates called `@user.password_reset_token` and
`@user.password_reset_token_expires_in`, which don't exist —
`generates_token_for` only adds class-level helpers, not instance
accessors. Every password reset email was failing at render time.
Use `generate_token_for(:password_reset)` and a literal expiry string
matching the 1-hour TTL on the token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:40:43 +10:00
41 changed files with 691 additions and 144 deletions

View File

@@ -1 +1 @@
4.0.1
4.0.3

View File

@@ -8,7 +8,7 @@
# 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
ARG RUBY_VERSION=4.0.1
ARG RUBY_VERSION=4.0.3
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch

View File

@@ -1,7 +1,7 @@
source "https://rubygems.org"
# 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]
gem "propshaft"
# Use sqlite3 as the database for Active Record

View File

@@ -1,31 +1,31 @@
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.16)
action_text-trix (2.1.18)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.2)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.2)
activesupport (= 8.1.2)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.2)
activesupport (= 8.1.2)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
timeout (>= 0.4.0)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0)
activesupport (8.1.2)
activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -75,19 +75,19 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.8)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
android_key_attestation (0.3.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.21)
bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2)
bigdecimal (4.0.1)
bigdecimal (4.1.2)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.20.1)
bootsnap (1.24.1)
msgpack (~> 1.2)
brakeman (7.1.2)
brakeman (8.0.4)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -102,7 +102,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cbor (0.5.10.1)
cbor (0.5.10.2)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
@@ -120,17 +120,17 @@ GEM
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.2)
erb (6.0.4)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm-linux-gnu)
ffi (1.17.4-arm-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
@@ -141,12 +141,12 @@ GEM
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.2.2)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.17.0)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
@@ -154,10 +154,10 @@ GEM
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.19.0)
json (2.19.4)
jwt (3.1.2)
base64
kamal (2.10.1)
kamal (2.11.0)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -177,7 +177,7 @@ GEM
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -193,7 +193,7 @@ GEM
mini_mime (1.1.5)
minitest (5.27.0)
msgpack (1.8.0)
net-imap (0.6.3)
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
@@ -206,68 +206,68 @@ GEM
net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1)
net-protocol
net-ssh (7.3.0)
net-ssh (7.3.2)
nio4r (2.7.5)
nokogiri (1.19.1-aarch64-linux-gnu)
nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-aarch64-linux-musl)
nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.1-arm-linux-gnu)
nokogiri (1.19.3-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-arm-linux-musl)
nokogiri (1.19.3-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl)
nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
openssl (4.0.0)
openssl (4.0.1)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
parallel (1.28.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.7.0)
propshaft (1.3.1)
prism (1.9.0)
propshaft (1.3.2)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.3.1)
date
stringio
public_suffix (7.0.0)
puma (7.1.0)
public_suffix (7.0.5)
puma (8.0.1)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.5)
rack-session (2.1.1)
rack (3.2.6)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
bundler (>= 1.15.0)
railties (= 8.1.2)
railties (= 8.1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -275,9 +275,9 @@ GEM
rails-html-sanitizer (1.7.0)
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)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -285,21 +285,21 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rake (13.4.2)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rotp (6.3.0)
rqrcode (3.1.1)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.1)
rubocop (1.81.7)
rqrcode_core (2.1.0)
rubocop (1.84.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -307,10 +307,10 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.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)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
@@ -325,18 +325,19 @@ GEM
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
securerandom (0.4.1)
selenium-webdriver (4.39.0)
selenium-webdriver (4.43.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.2.0)
sentry-rails (6.5.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.2.0)
sentry-ruby (6.2.0)
sentry-ruby (~> 6.5.0)
sentry-ruby (6.5.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
logger
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
@@ -352,20 +353,20 @@ GEM
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.2.4)
solid_queue (1.4.0)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
sqlite3 (2.9.3-aarch64-linux-gnu)
sqlite3 (2.9.3-aarch64-linux-musl)
sqlite3 (2.9.3-arm-linux-gnu)
sqlite3 (2.9.3-arm-linux-musl)
sqlite3 (2.9.3-arm64-darwin)
sqlite3 (2.9.3-x86_64-linux-gnu)
sqlite3 (2.9.3-x86_64-linux-musl)
sshkit (1.25.0)
base64
logger
@@ -373,10 +374,10 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
standard (1.52.0)
standard (1.54.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.81.7)
rubocop (~> 1.84.0)
standard-custom (~> 1.0.0)
standard-performance (~> 1.8)
standard-custom (1.0.2)
@@ -391,24 +392,24 @@ GEM
tailwindcss-rails (4.4.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.1.18)
tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
tailwindcss-ruby (4.1.18-aarch64-linux-musl)
tailwindcss-ruby (4.1.18-arm64-darwin)
tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
tailwindcss-ruby (4.1.18-x86_64-linux-musl)
tailwindcss-ruby (4.2.4)
tailwindcss-ruby (4.2.4-aarch64-linux-gnu)
tailwindcss-ruby (4.2.4-aarch64-linux-musl)
tailwindcss-ruby (4.2.4-arm64-darwin)
tailwindcss-ruby (4.2.4-x86_64-linux-gnu)
tailwindcss-ruby (4.2.4-x86_64-linux-musl)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-linux)
timeout (0.6.0)
thruster (0.1.20)
thruster (0.1.20-aarch64-linux)
thruster (0.1.20-arm64-darwin)
thruster (0.1.20-x86_64-linux)
timeout (0.6.1)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
tsort (0.2.0)
turbo-rails (2.0.20)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -418,11 +419,10 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
railties (>= 8.0.0)
webauthn (3.4.3)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
@@ -469,7 +469,7 @@ DEPENDENCIES
propshaft
public_suffix (~> 7.0)
puma (>= 5.0)
rails (~> 8.1.2)
rails (~> 8.1.3)
rotp (~> 6.3)
rqrcode (~> 3.1)
selenium-webdriver
@@ -490,4 +490,4 @@ DEPENDENCIES
webauthn (~> 3.0)
BUNDLED WITH
4.0.3
4.0.6

View File

@@ -14,6 +14,7 @@ class ApiKeysController < ApplicationController
@api_key = Current.session.user.api_keys.build(api_key_params)
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
redirect_to api_key_path(@api_key)
else
@@ -31,6 +32,7 @@ class ApiKeysController < ApplicationController
def destroy
@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."
end

View File

@@ -14,6 +14,10 @@ class ApplicationController < ActionController::Base
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
# More robust than regex - handles URL encoding, edge cases, etc.
#

View File

@@ -73,7 +73,15 @@ module Authentication
# Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present?
cookies.signed.permanent[:session_id] = cookie_options
# 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
else
cookies.signed[:session_id] = cookie_options
end
# Create a one-time token for immediate forward auth after authentication
# This solves the race condition where browser hasn't processed cookie yet

View File

@@ -20,6 +20,7 @@ class PasswordsController < ApplicationController
def update
if @user.update(params.permit(:password, :password_confirmation))
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
@user.sessions.destroy_all
redirect_to signin_path, notice: "Password has been reset."
else

View File

@@ -15,6 +15,7 @@ class ProfilesController < ApplicationController
end
if @user.update(password_params)
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
redirect_to profile_path, notice: "Password updated successfully."
else
render :show, status: :unprocessable_entity
@@ -27,7 +28,15 @@ class ProfilesController < ApplicationController
return
end
old_email = @user.email_address
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."
else
render :show, status: :unprocessable_entity

View File

@@ -103,6 +103,7 @@ class TotpController < ApplicationController
# Generate new backup codes and store BCrypt hashes
plain_codes = @user.send(:generate_backup_codes)
@user.save!
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
# Store plain codes temporarily in session for display
session[:temp_backup_codes] = plain_codes
@@ -136,6 +137,7 @@ class TotpController < ApplicationController
end
@user.disable_totp!
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
end

View File

@@ -91,6 +91,8 @@ class WebauthnController < ApplicationController
backup_state: backup_state
)
SecurityMailer.passkey_added(user, nickname: @webauthn_credential.nickname, **security_event_context).deliver_later
render json: {
success: true,
message: "Passkey '#{nickname}' registered successfully",
@@ -109,8 +111,11 @@ class WebauthnController < ApplicationController
# Remove a passkey
def destroy
nickname = @webauthn_credential.nickname
user = @webauthn_credential.user
@webauthn_credential.destroy
SecurityMailer.passkey_removed(user, nickname: nickname, **security_event_context).deliver_later
respond_to do |format|
format.html {
redirect_to profile_path,

View File

@@ -20,6 +20,21 @@ module ApplicationHelper
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)
case type.to_s
when "notice" then "border-green-200 dark:border-green-700"

View 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

View 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

View File

@@ -25,6 +25,23 @@
Public clients do not have a client secret. PKCE is required.
</div>
<% 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 &amp; 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>
<% end %>
@@ -157,6 +174,30 @@
</dd>
</div>
<% 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 %>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>

View File

@@ -1,6 +1,6 @@
<p>
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>

View File

@@ -1,4 +1,4 @@
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.

View 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>

View 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.

View 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" %>

View 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" %>

View 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" %>

View 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" %>

View File

@@ -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" %>

View File

@@ -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" %>

View 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" %>

View 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" %>

View 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" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new passkey ("<%= @nickname %>") was just added to your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View 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" %>

View File

@@ -0,0 +1,6 @@
Hello,
A passkey ("<%= @nickname %>") was just removed from your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View 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" %>

View File

@@ -0,0 +1,5 @@
Hello,
The password on your Clinch account (<%= @user.email_address %>) was just changed.
<%= render "event_metadata" %>

View 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" %>

View File

@@ -0,0 +1,6 @@
Hello,
Two-factor authentication was just disabled on your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -38,10 +38,6 @@ env:
secret:
- RAILS_MASTER_KEY
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)
# JOB_CONCURRENCY: 3

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Clinch
VERSION = "0.9.0"
VERSION = "0.10.1"
end

View File

@@ -34,7 +34,9 @@ port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command.
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.
# In other environments, only set the PID file if requested.

View File

@@ -30,4 +30,22 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to signin_path
assert_empty cookies[:session_id]
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

View 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

View 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