11 Commits

Author SHA1 Message Date
Dan Milne
c85d25c4b9 Untrack SECURITY_REVIEW_TODO.md and gitignore it
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
Keep the findings tracker local-only; it should not be published.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 23:09:17 +10:00
Dan Milne
1b0d323572 Bump version to 0.16.3
Some checks failed
Build and publish image / prepare (push) Has been cancelled
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
Build and publish image / build (amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and publish image / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and publish image / merge (push) Has been cancelled
Ships the access-check GET-form fix (782e197) as a published image.
v0.16.2 was bumped before the version-bump build workflow existed, so it
never built; this bump triggers the build via the registered push trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:29:39 +10:00
Dan Milne
d1d626c540 Rework build workflow to trigger on version bump + manual dispatch
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
Port the build pipeline from the splat sibling project. Instead of
triggering on git tags, the image now builds when
config/initializers/version.rb changes on main — a version bump IS the
release — plus a workflow_dispatch button for manual builds.

Reads Clinch::VERSION, tags the image :vX.Y.Z, and moves :latest only
for non-pre-release versions. Also builds multi-arch (amd64 + arm64) on
native runners and stitches a manifest, replacing the amd64-only build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:08:04 +10:00
Dan Milne
782e197d91 Fix access check form: use GET so results render
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
Build and publish image / build (push) Has been cancelled
The access check form POSTed and re-rendered :new with a 200 HTML
response, which Turbo rejects ("Form responses must redirect to
another location"), so the result panel never appeared. Since the
check is a read-only query, switch to a GET form and fold the lookup
into the new action. Results are now bookmarkable via the URL.

Bump version to 0.16.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:42:57 +10:00
Dan Milne
020759bfb3 Fix invalid require-trusted-types-for CSP directive
require-trusted-types-for only accepts 'script'; emitting 'none'
produced an invalid directive that browsers rejected. Omit the
directive entirely to leave Trusted Types unenforced (needed for
WebAuthn). Bump version to 0.16.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:39:35 +10:00
Dan Milne
85f50bfc96 Add GitHub Actions workflow to build and publish image to GHCR
Builds the production Docker image and pushes it to
ghcr.io/dkam/clinch on pushes to main (edge + sha tags) and on v*
release tags (vX.Y.Z, vX.Y, latest). amd64 only, with GHA layer caching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:02:29 +10:00
Dan Milne
b55139eb1c Fix Sentry config to use Sentry.init API
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
The Sentry setup used a config.sentry.* accessor that sentry-rails has
never provided, so booting with SENTRY_DSN set raised NoMethodError
during environment load (e.g. db:prepare). The code only ran once a DSN
was configured, which is why it surfaced in production now.

Rewrites config/initializers/sentry.rb to call Sentry.init, the actual
sentry-ruby API, and removes the duplicate broken block from
production.rb. Verified production boots with SENTRY_DSN set
(Sentry.initialized? == true) and that the no-DSN path still early-returns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:57:26 +10:00
Dan Milne
8f578ed3f4 Upgrade Ruby to 4.0.5
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
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:51:28 +10:00
Dan Milne
aa5736ddab Update gems and fix lint to clear CI failures
Bumps dependencies (jwt 3.2.0, puma 8.0.2, net-imap 0.6.4.1 and others
via bundle update) to resolve bundler-audit advisories, and applies
standardrb autofixes so the lint job passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:51:23 +10:00
Dan Milne
49068aa344 Add tests
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
2026-06-15 08:22:23 +10:00
Dan Milne
07ea031b61 Remove hardcoded internal IP from production hosts allowlist
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
192.168.2.246 was redundant with the 192.168.0.0/16 regex already in the
CLINCH_ALLOW_INTERNAL_IPS block, and baked a specific lab IP into the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:55:02 +10:00
23 changed files with 318 additions and 212 deletions

133
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: Build and publish image
# Publishes the multi-arch image (amd64 + arm64) to GitHub Packages
# (ghcr.io/dkam/clinch) whenever config/initializers/version.rb changes on
# main — a version bump IS the release. Each arch builds natively (no QEMU); a
# merge job stitches them into one manifest tagged :vX.Y.Z (+ :latest for
# non-pre-releases).
#
# To cut a release: edit Clinch::VERSION in config/initializers/version.rb,
# commit, push. For a dev build: set a pre-release version (e.g. "1.1.0-dev") —
# it publishes :v1.1.0-dev but does not move :latest. Or run this workflow
# manually from the Actions tab.
on:
push:
branches: [ main ]
paths:
- config/initializers/version.rb
workflow_dispatch:
env:
IMAGE: ghcr.io/${{ github.repository }}
jobs:
# Read the SemVer constant; decide whether this release moves :latest.
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
latest: ${{ steps.version.outputs.latest }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Read version from config/initializers/version.rb
id: version
run: |
V=$(ruby -e "require './config/initializers/version'; puts Clinch::VERSION")
echo "version=$V" >> "$GITHUB_OUTPUT"
# A pre-release (e.g. 1.1.0-dev) publishes its own tag but not :latest.
if [[ "$V" == *-* ]]; then latest=false; else latest=true; fi
echo "latest=$latest" >> "$GITHUB_OUTPUT"
echo "Building v$V (move :latest = $latest)"
build:
needs: prepare
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-latest
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push the multi-arch manifest
working-directory: /tmp/digests
run: |
tags="-t ${{ env.IMAGE }}:v${{ needs.prepare.outputs.version }}"
if [ "${{ needs.prepare.outputs.latest }}" = "true" ]; then
tags="$tags -t ${{ env.IMAGE }}:latest"
fi
docker buildx imagetools create $tags $(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect result
run: docker buildx imagetools inspect ${{ env.IMAGE }}:latest

3
.gitignore vendored
View File

@@ -70,3 +70,6 @@ yarn-debug.log*
# Ignore bootsnap cache # Ignore bootsnap cache
/tmp/cache/bootsnap* /tmp/cache/bootsnap*
# Local-only: do not publish the security findings tracker
SECURITY_REVIEW_TODO.md

View File

@@ -1 +1 @@
4.0.3 4.0.5

View File

@@ -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.3 ARG RUBY_VERSION=4.0.5
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

View File

@@ -1,7 +1,7 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.18) action_text-trix (2.1.19)
railties railties
actioncable (8.1.3) actioncable (8.1.3)
actionpack (= 8.1.3) actionpack (= 8.1.3)
@@ -85,9 +85,9 @@ GEM
bigdecimal (4.1.2) bigdecimal (4.1.2)
bindata (2.5.1) bindata (2.5.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.24.1) bootsnap (1.24.6)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (8.0.4) brakeman (8.0.5)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.3) bundler-audit (0.9.3)
@@ -102,11 +102,11 @@ 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.2) cbor (0.5.10.3)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
chunky_png (1.4.0) chunky_png (1.4.0)
concurrent-ruby (1.3.6) concurrent-ruby (1.3.7)
connection_pool (3.0.2) connection_pool (3.0.2)
cose (1.3.1) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
@@ -131,12 +131,12 @@ GEM
ffi (1.17.4-arm64-darwin) ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl) ffi (1.17.4-x86_64-linux-musl)
fugit (1.12.1) fugit (1.12.2)
et-orbi (~> 1.4) et-orbi (~> 1.4)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.8) i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0) image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6) mini_magick (>= 4.9.5, < 6)
@@ -151,13 +151,13 @@ GEM
prism (>= 1.3.0) prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1) jbuilder (2.15.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.19.4) json (2.19.9)
jwt (3.1.2) jwt (3.2.0)
base64 base64
kamal (2.11.0) kamal (2.12.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
@@ -186,14 +186,14 @@ GEM
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.1.0) marcel (1.2.1)
matrix (0.4.3) matrix (0.4.3)
mini_magick (5.3.1) mini_magick (5.3.1)
logger logger
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.3)
net-imap (0.6.4) net-imap (0.6.4.1)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -208,25 +208,25 @@ GEM
net-protocol net-protocol
net-ssh (7.3.2) net-ssh (7.3.2)
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.3-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-aarch64-linux-musl) nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-arm-linux-gnu) nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-arm-linux-musl) nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin) nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-musl) nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
openssl (4.0.1) openssl (4.0.2)
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.28.0) parallel (2.1.0)
parser (3.3.11.1) parser (3.3.11.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
@@ -238,11 +238,11 @@ GEM
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
psych (5.3.1) psych (5.4.0)
date date
stringio stringio
public_suffix (7.0.5) public_suffix (7.0.5)
puma (8.0.1) puma (8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
@@ -299,11 +299,11 @@ GEM
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.1.0) rqrcode_core (2.1.0)
rubocop (1.84.2) rubocop (1.87.0)
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)
parallel (~> 1.10) parallel (>= 1.10)
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)
@@ -321,20 +321,20 @@ GEM
ruby-vips (2.3.0) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.2) rubyzip (3.4.0)
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.43.0) selenium-webdriver (4.45.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.5.0) sentry-rails (6.6.2)
railties (>= 5.2.0) railties (>= 5.2.0)
sentry-ruby (~> 6.5.0) sentry-ruby (~> 6.6.2)
sentry-ruby (6.5.0) sentry-ruby (6.6.2)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
logger logger
@@ -344,7 +344,7 @@ GEM
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
solid_cable (3.0.12) solid_cable (4.0.0)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
@@ -360,13 +360,13 @@ GEM
fugit (~> 1.11) fugit (~> 1.11)
railties (>= 7.1) railties (>= 7.1)
thor (>= 1.3.1) thor (>= 1.3.1)
sqlite3 (2.9.3-aarch64-linux-gnu) sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.3-aarch64-linux-musl) sqlite3 (2.9.5-aarch64-linux-musl)
sqlite3 (2.9.3-arm-linux-gnu) sqlite3 (2.9.5-arm-linux-gnu)
sqlite3 (2.9.3-arm-linux-musl) sqlite3 (2.9.5-arm-linux-musl)
sqlite3 (2.9.3-arm64-darwin) sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.3-x86_64-linux-gnu) sqlite3 (2.9.5-x86_64-linux-gnu)
sqlite3 (2.9.3-x86_64-linux-musl) sqlite3 (2.9.5-x86_64-linux-musl)
sshkit (1.25.0) sshkit (1.25.0)
base64 base64
logger logger
@@ -374,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.54.0) standard (1.55.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.84.0) rubocop (~> 1.87.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)
@@ -389,20 +389,20 @@ GEM
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.2.0) stringio (3.2.0)
tailwindcss-rails (4.4.0) tailwindcss-rails (4.6.0)
railties (>= 7.0.0) railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0) tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.2.4) tailwindcss-ruby (4.3.1)
tailwindcss-ruby (4.2.4-aarch64-linux-gnu) tailwindcss-ruby (4.3.1-aarch64-linux-gnu)
tailwindcss-ruby (4.2.4-aarch64-linux-musl) tailwindcss-ruby (4.3.1-aarch64-linux-musl)
tailwindcss-ruby (4.2.4-arm64-darwin) tailwindcss-ruby (4.3.1-arm64-darwin)
tailwindcss-ruby (4.2.4-x86_64-linux-gnu) tailwindcss-ruby (4.3.1-x86_64-linux-gnu)
tailwindcss-ruby (4.2.4-x86_64-linux-musl) tailwindcss-ruby (4.3.1-x86_64-linux-musl)
thor (1.5.0) thor (1.5.0)
thruster (0.1.20) thruster (0.1.21)
thruster (0.1.20-aarch64-linux) thruster (0.1.21-aarch64-linux)
thruster (0.1.20-arm64-darwin) thruster (0.1.21-arm64-darwin)
thruster (0.1.20-x86_64-linux) thruster (0.1.21-x86_64-linux)
timeout (0.6.1) timeout (0.6.1)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
@@ -432,13 +432,13 @@ GEM
safety_net_attestation (~> 0.5.0) safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0) tpm-key_attestation (~> 0.14.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.0) websocket-driver (0.8.1)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.5) zeitwerk (2.8.2)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux

View File

@@ -2,17 +2,12 @@ module Admin
class AccessChecksController < BaseController class AccessChecksController < BaseController
def new def new
load_options load_options
end
def create
load_options
@user = User.find_by(id: params[:user_id]) @user = User.find_by(id: params[:user_id])
@application = Application.find_by(id: params[:application_id]) @application = Application.find_by(id: params[:application_id])
return render :new unless @user && @application return unless @user && @application
@allowed = @application.user_allowed?(@user) @allowed = @application.user_allowed?(@user)
@via = @user.groups & @application.allowed_groups @via = @user.groups & @application.allowed_groups
render :new
end end
private private

View File

@@ -156,7 +156,7 @@ module Api
end end
def render_bearer_error(message) def render_bearer_error(message)
render json: { error: message }, status: :unauthorized render json: {error: message}, status: :unauthorized
end end
def check_forward_auth_token def check_forward_auth_token
@@ -207,7 +207,7 @@ module Api
session[:return_to_after_authenticating] = original_url session[:return_to_after_authenticating] = original_url
login_params = { rd: original_url, rm: request.method } login_params = {rd: original_url, rm: request.method}
login_url = "#{base_url}/signin?#{login_params.to_query}" login_url = "#{base_url}/signin?#{login_params.to_query}"
redirect_to login_url, allow_other_host: true, status: :found redirect_to login_url, allow_other_host: true, status: :found

View File

@@ -62,9 +62,14 @@ module Authentication
return if redirect_host.blank? return if redirect_host.blank?
csp = request.content_security_policy csp = request.content_security_policy
return unless csp&.respond_to?(:form_action) && csp.form_action.respond_to?(:<<) return unless csp
csp.form_action << "https://#{redirect_host}" # NOTE: `csp.form_action` (no args) is destructive — it deletes the directive
# and returns its old value, so reading it twice yields nil. Mutate the
# underlying `directives` hash (a public reader of the real values) instead.
form_action = (csp.directives["form-action"] ||= ["'self'"])
host = "https://#{redirect_host}"
form_action << host unless form_action.include?(host)
rescue URI::InvalidURIError rescue URI::InvalidURIError
nil nil
end end
@@ -186,7 +191,7 @@ module Authentication
token = SecureRandom.urlsafe_base64(32) token = SecureRandom.urlsafe_base64(32)
Rails.cache.write( Rails.cache.write(
"forward_auth_token:#{token}", "forward_auth_token:#{token}",
{ session_id: session_obj.id, host: bound_host }, {session_id: session_obj.id, host: bound_host},
expires_in: 60.seconds expires_in: 60.seconds
) )

View File

@@ -31,7 +31,7 @@ module ApplicationHelper
end end
lines << "OIDC_DISCOVERY_URL=#{OidcJwtService.issuer_url}" lines << "OIDC_DISCOVERY_URL=#{OidcJwtService.issuer_url}"
lines << "OIDC_PROVIDER_NAME='Clinch'" lines << "OIDC_PROVIDER_NAME='Clinch'"
lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? 'true' : 'false'}" lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? "true" : "false"}"
lines lines
end end

View File

@@ -35,7 +35,7 @@ module PrivateAddressCheck
return [ip] if ip return [ip] if ip
Resolv.getaddresses(host.to_s).filter_map { |a| parse_ip(a) } Resolv.getaddresses(host.to_s).filter_map { |a| parse_ip(a) }
rescue StandardError rescue
# Resolution failure: surface no addresses. Callers treat "can't resolve" as # Resolution failure: surface no addresses. Callers treat "can't resolve" as
# not-provably-internal; the dial itself will then fail safely. # not-provably-internal; the dial itself will then fail safely.
[] []

View File

@@ -2,6 +2,6 @@ class TotpMailer < ApplicationMailer
def enabled(user) def enabled(user)
@user = user @user = user
mail subject: "Two-factor authentication enabled on your account", mail subject: "Two-factor authentication enabled on your account",
to: user.email_address to: user.email_address
end end
end end

View File

@@ -5,7 +5,7 @@
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg"> <div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<%= form_with url: admin_access_path, method: :post, class: "space-y-4" do |form| %> <%= form_with url: admin_access_path, method: :get, class: "space-y-4" do |form| %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<%= form.label :user_id, "User", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> <%= form.label :user_id, "User", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>

View File

@@ -139,9 +139,6 @@ Rails.application.configure do
# Allow internal IP access for cross-compose or host networking # Allow internal IP access for cross-compose or host networking
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true" if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
# Specific host IP
allowed_hosts << "192.168.2.246"
# Private IP ranges for internal network access # Private IP ranges for internal network access
allowed_hosts += [ allowed_hosts += [
/192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network /192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network
@@ -160,17 +157,5 @@ Rails.application.configure do
# Skip DNS rebinding protection for the default health check endpoint. # Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = {exclude: ->(request) { request.path == "/up" }} config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
# Sentry configuration for production # Sentry is configured in config/initializers/sentry.rb, gated on SENTRY_DSN.
# Only enabled if SENTRY_DSN environment variable is set
if ENV["SENTRY_DSN"].present?
config.sentry.enabled = true
# Performance monitoring: sample 20% of transactions for traces
# Adjust based on your traffic volume and Sentry plan limits
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.2).to_f
# Continuous profiling: disabled by default in production due to cost
# Enable temporarily for performance investigations if needed
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
end
end end

View File

@@ -53,9 +53,10 @@ Rails.application.configure do
# Child sources: Allow self for any future iframes # Child sources: Allow self for any future iframes
policy.child_src :self policy.child_src :self
# Additional security headers for WebAuthn # Do not enforce Trusted Types. The only valid value for
# Required for WebAuthn to work properly # require-trusted-types-for is 'script'; there is no 'none' token, so
policy.require_trusted_types_for :none # emitting it produces an invalid directive that browsers reject. To leave
# Trusted Types unenforced (needed for WebAuthn), omit the directive entirely.
# CSP reporting using report_uri (supported method) # CSP reporting using report_uri (supported method)
policy.report_uri "/api/csp-violation-report" policy.report_uri "/api/csp-violation-report"

View File

@@ -1,62 +1,44 @@
# Sentry configuration for error tracking and performance monitoring # Sentry configuration for error tracking and performance monitoring.
# Only initializes if SENTRY_DSN environment variable is set # Only initializes if the SENTRY_DSN environment variable is set.
return unless ENV["SENTRY_DSN"].present? return unless ENV["SENTRY_DSN"].present?
Rails.application.configure do Sentry.init do |config|
config.sentry.dsn = ENV["SENTRY_DSN"] config.dsn = ENV["SENTRY_DSN"]
# Set environment (defaults to Rails.env) # Environment label (defaults to Rails.env)
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env config.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
# Set release version from Git or environment variable # Release version from an env var or the current Git SHA
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil config.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence
# Sample rate for performance monitoring (0.0 to 1.0) # Only report from production unless explicitly enabled elsewhere.
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f config.enabled_environments =
if ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
# Enable profiling in development/staging, disable in production unless explicitly enabled %w[production development]
config.sentry.profiles_sample_rate = if Rails.env.production? else
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f %w[production]
else
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
end
# Include additional context
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end end
# Filter sensitive parameters # Don't send cookies, request bodies, or user IPs by default.
if event.context[:request] config.send_default_pii = false
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i) # Breadcrumbs for debugging
} config.breadcrumbs_logger = [:active_support_logger, :http_logger]
# Performance monitoring sample rate (0.0 to 1.0)
config.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
# Profiling: disabled in production by default due to cost.
config.profiles_sample_rate =
if Rails.env.production?
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
else
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
end end
event
end
# Include breadcrumbs for debugging
config.sentry.breadcrumbs_logger = [:active_support_logger, :http_logger]
# Send session data for user context
config.sentry.user_context = lambda do
if Current.user.present?
{
id: Current.user.id,
email: Current.user.email_address,
admin: Current.user.admin?
}
end
end
# Ignore common non-critical exceptions # Ignore common non-critical exceptions
config.sentry.excluded_exceptions += [ config.excluded_exceptions += [
"ActionController::RoutingError", "ActionController::RoutingError",
"ActionController::InvalidAuthenticityToken", "ActionController::InvalidAuthenticityToken",
"ActionController::UnknownFormat", "ActionController::UnknownFormat",
@@ -66,75 +48,38 @@ Rails.application.configure do
"ActiveRecord::RecordNotFound" "ActiveRecord::RecordNotFound"
] ]
# Add CSP-specific tags for security events # Attach application/user context and scrub anything sensitive before sending.
config.sentry.tags = lambda do config.before_send = lambda do |event, _hint|
{ event.tags = (event.tags || {}).merge(
# Add application context
app_name: "clinch", app_name: "clinch",
app_environment: Rails.env, app_environment: Rails.env
# Add CSP policy status )
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
Rails.application.config.content_security_policy.present?
}
end
# Enhance before_send to handle CSP events properly if defined?(Current) && Current.respond_to?(:user) && Current.user
config.sentry.before_send = lambda do |event, hint| event.user = (event.user || {}).merge(
# Filter out sensitive information id: Current.user.id,
if event.context[:extra] email: Current.user.email_address,
event.context[:extra].reject! { |key, value| admin: Current.user.admin?
)
end
if event.extra.is_a?(Hash)
event.extra.reject! do |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i) key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
} end
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
# Special handling for CSP violations
if event.tags&.dig(:csp_violation)
# Ensure CSP violations have proper security context
event.context[:server] = event.context[:server] || {}
event.context[:server][:name] = "clinch-auth-service"
event.context[:server][:environment] = Rails.env
# Add additional security context
event.context[:extra] ||= {}
event.context[:extra][:security_context] = {
csp_reporting: true,
user_authenticated: event.context[:user].present?,
request_origin: event.context[:request]&.dig(:headers, "Origin"),
request_referer: event.context[:request]&.dig(:headers, "Referer")
}
end end
event event
end end
# Add CSP-specific breadcrumbs for security events # Scrub sensitive data out of breadcrumbs.
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint| config.before_breadcrumb = lambda do |breadcrumb, _hint|
# Filter out sensitive breadcrumb data if breadcrumb.data.is_a?(Hash)
if breadcrumb[:data] breadcrumb.data.reject! do |key, value|
breadcrumb[:data].reject! { |key, value| key.to_s.match?(/password|secret|token|key|authorization/i) || value.to_s.match?(/password|secret/i)
key.to_s.match?(/password|secret|token|key|authorization/i) || end
value.to_s.match?(/password|secret/i)
}
end
# Mark CSP-related events
if breadcrumb[:message]&.include?("CSP Violation") ||
breadcrumb[:category]&.include?("csp")
breadcrumb[:data] ||= {}
breadcrumb[:data][:security_event] = true
breadcrumb[:data][:csp_violation] = true
end end
breadcrumb breadcrumb
end end
# Only send errors in production unless explicitly enabled
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
end end

View File

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

View File

@@ -96,7 +96,6 @@ Rails.application.routes.draw do
end end
resources :groups resources :groups
get "access", to: "access_checks#new" get "access", to: "access_checks#new"
post "access", to: "access_checks#create"
end end
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)

View File

@@ -1,8 +1,8 @@
class AddOidcAuthorizationCodeIdToTokens < ActiveRecord::Migration[8.1] class AddOidcAuthorizationCodeIdToTokens < ActiveRecord::Migration[8.1]
def change def change
add_reference :oidc_access_tokens, :oidc_authorization_code, add_reference :oidc_access_tokens, :oidc_authorization_code,
null: true, foreign_key: true, index: true null: true, foreign_key: true, index: true
add_reference :oidc_refresh_tokens, :oidc_authorization_code, add_reference :oidc_refresh_tokens, :oidc_authorization_code,
null: true, foreign_key: true, index: true null: true, foreign_key: true, index: true
end end
end end

View File

@@ -15,8 +15,8 @@ module Admin
assert_match "alice@example.com", response.body assert_match "alice@example.com", response.body
end end
test "create returns 'can access' with via group when user is in an allowed group" do test "returns 'can access' with via group when user is in an allowed group" do
post admin_access_path, params: { get admin_access_path, params: {
user_id: users(:alice).id, user_id: users(:alice).id,
application_id: @kavita.id application_id: @kavita.id
} }
@@ -25,9 +25,9 @@ module Admin
assert_match "Administrators", response.body # alice is in admin_group; kavita has admin_group assert_match "Administrators", response.body # alice is in admin_group; kavita has admin_group
end end
test "create returns 'cannot access' with reason when user shares no group with the app" do test "returns 'cannot access' with reason when user shares no group with the app" do
lonely = User.create!(email_address: "lonely@example.com", password: "password123", skip_auto_assign: true) lonely = User.create!(email_address: "lonely@example.com", password: "password123", skip_auto_assign: true)
post admin_access_path, params: { get admin_access_path, params: {
user_id: lonely.id, user_id: lonely.id,
application_id: @kavita.id application_id: @kavita.id
} }
@@ -36,8 +36,8 @@ module Admin
assert_match "shares no group", response.body assert_match "shares no group", response.body
end end
test "create renders form unchanged when ids are missing" do test "renders form unchanged when ids are missing" do
post admin_access_path, params: {user_id: "", application_id: ""} get admin_access_path, params: {user_id: "", application_id: ""}
assert_response :success assert_response :success
# No result panel should render. The panel-only phrases: # No result panel should render. The panel-only phrases:
refute_match "Granted via", response.body refute_match "Granted via", response.body

View File

@@ -27,7 +27,7 @@ module Admin
@group.applications = [applications(:kavita_app)] @group.applications = [applications(:kavita_app)]
patch admin_group_path(@group), params: { patch admin_group_path(@group), params: {
group: { name: @group.name } group: {name: @group.name}
} }
assert_redirected_to admin_group_path(@group) assert_redirected_to admin_group_path(@group)

View File

@@ -186,7 +186,7 @@ module Api
# Under default-deny the user must be in at least one group to access the app. # Under default-deny the user must be in at least one group to access the app.
# This rewritten test verifies that when an app's headers_config disables the # This rewritten test verifies that when an app's headers_config disables the
# groups header, no x-remote-groups is sent regardless of memberships. # groups header, no x-remote-groups is sent regardless of memberships.
app = grant_everyone_access Application.create!( grant_everyone_access Application.create!(
name: "Headers Hidden", slug: "headers-hidden", app_type: "forward_auth", name: "Headers Hidden", slug: "headers-hidden", app_type: "forward_auth",
domain_pattern: "hidden.example.com", domain_pattern: "hidden.example.com",
active: true, active: true,
@@ -559,7 +559,7 @@ module Api
end end
test "should track failed attempts and eventually rate limit" do test "should track failed attempts and eventually rate limit" do
cache = Rails.application.config.forward_auth_cache Rails.application.config.forward_auth_cache
# Make 50 failed requests (no session = unauthorized) # Make 50 failed requests (no session = unauthorized)
50.times do 50.times do

View File

@@ -32,6 +32,42 @@ class CspTest < ActionDispatch::IntegrationTest
"inline theme script must carry the matching CSP nonce") "inline theme script must carry the matching CSP nonce")
end end
test "signin page adds the OAuth redirect_uri host to form-action without 500ing" do
# A user must exist, otherwise /signin redirects to signup before the CSP
# branch runs.
User.create!(email_address: "csp_oauth@example.com", password: "password123")
app = Application.create!(
name: "CSP OAuth App",
slug: "csp-oauth-app",
app_type: "oidc",
redirect_uris: ["https://app.example.com/callback"].to_json,
active: true,
require_pkce: false
)
# An unauthenticated authorize request stores the full /oauth/authorize URL
# in the session and redirects to signin (oidc_controller.rb:202).
get "/oauth/authorize", params: {
client_id: app.client_id,
redirect_uri: app.parsed_redirect_uris.first,
response_type: "code",
scope: "openid"
}
assert_redirected_to signin_path
# Following to signin must reach allow_oauth_redirect_in_csp without raising.
# Regression: csp.form_action is a destructive getter, so reading it twice
# returned nil and `nil << host` raised NoMethodError -> 500.
follow_redirect!
assert_response :success
form_action = directive(response.headers["Content-Security-Policy"], "form-action")
assert_includes form_action, "'self'", "form-action must keep its default 'self'"
assert_includes form_action, "https://app.example.com",
"form-action must include the OAuth client's redirect_uri host"
end
private private
def directive(csp, name) def directive(csp, name)

View File

@@ -17,7 +17,11 @@ module SessionTestHelper
# written under the old "empty allowed_groups = public" rule keep working. # written under the old "empty allowed_groups = public" rule keep working.
# New tests should attach groups explicitly to model real access intent. # New tests should attach groups explicitly to model real access intent.
def grant_everyone_access(app) def grant_everyone_access(app)
everyone = (groups(:everyone) rescue Group.find_by(auto_assign: true)) everyone = begin
groups(:everyone)
rescue
Group.find_by(auto_assign: true)
end
app.allowed_groups << everyone unless app.allowed_groups.include?(everyone) app.allowed_groups << everyone unless app.allowed_groups.include?(everyone)
app app
end end