Compare commits
8 Commits
209c5496d8
...
v0.16.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782e197d91 | ||
|
|
020759bfb3 | ||
|
|
85f50bfc96 | ||
|
|
b55139eb1c | ||
|
|
8f578ed3f4 | ||
|
|
aa5736ddab | ||
|
|
49068aa344 | ||
|
|
07ea031b61 |
56
.github/workflows/build.yml
vendored
Normal file
56
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: Build and publish image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
tags: [ 'v*' ]
|
||||||
|
|
||||||
|
# Only one build per ref at a time; cancel superseded main builds.
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write # Required to push to GHCR
|
||||||
|
|
||||||
|
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: Extract image metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=edge,branch=main
|
||||||
|
type=sha,prefix=sha-,format=short,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern=v{{version}}
|
||||||
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
|
flavor: |
|
||||||
|
latest=auto
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -1 +1 @@
|
|||||||
4.0.3
|
4.0.5
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
110
Gemfile.lock
110
Gemfile.lock
@@ -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
|
||||||
|
|||||||
110
SECURITY_REVIEW_TODO.md
Normal file
110
SECURITY_REVIEW_TODO.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Security Review — Tracking
|
||||||
|
|
||||||
|
Status of findings from the multi-surface security review (OIDC/OAuth2, ForwardAuth,
|
||||||
|
WebAuthn/TOTP, sessions, admin/config). Work landed on branch
|
||||||
|
`security/forward-auth-and-consent-csrf`.
|
||||||
|
|
||||||
|
## ✅ Done (branch `security/forward-auth-and-consent-csrf`)
|
||||||
|
|
||||||
|
All HIGH findings are closed. Each fix has tests; suite is green.
|
||||||
|
|
||||||
|
| Commit | Fix | Sev |
|
||||||
|
|--------|-----|-----|
|
||||||
|
| `703d24e` | ForwardAuth fail-open when no host header; consent endpoint CSRF | HIGH ×2 |
|
||||||
|
| `8a095e4` | Bearer API-key skipped group check at use-time | HIGH |
|
||||||
|
| `96a657e` | Open redirect via unvalidated `X-Forwarded-Host` in login redirect | HIGH |
|
||||||
|
| `84ed462` | `CLINCH_HOST` made mandatory in deployed envs; dropped request-host fallback | MEDIUM |
|
||||||
|
| `f38ac2e` | TOTP code replay within drift window (+ latent plaintext backup-code bug) | HIGH |
|
||||||
|
| `406a79d` | SSRF via `backchannel_logout_uri` (metadata/loopback/RFC1918) | HIGH |
|
||||||
|
| `57d7d1f` | Host-auth regex unanchored (`evil-example.com` matched) | HIGH |
|
||||||
|
| `89bd5f1` | Disabled user could complete 2FA mid-flow / keep session; enforce active status | HIGH |
|
||||||
|
| `cd862c7` | TOTP/backup/OAuth/PKCE `code` params not filtered from logs | MEDIUM |
|
||||||
|
| `2426687` | `revoke_family!` didn't revoke access tokens on refresh-token reuse | HIGH |
|
||||||
|
| `44892e3` | WebAuthn clone detection logged but didn't block; false-positive on synced passkeys | HIGH |
|
||||||
|
| `d49e7ce` | CSP `unsafe-inline` removed (script-src + style-src → nonces) | HIGH |
|
||||||
|
|
||||||
|
**Verified false positive (no change):** PKCE *is* required by default —
|
||||||
|
`require_pkce` column defaults to `true` (`db/schema.rb`), token endpoint enforces
|
||||||
|
it, admin UI exposes the opt-out. Operational check: confirm no legacy confidential
|
||||||
|
apps sit on `require_pkce = false`.
|
||||||
|
|
||||||
|
**Follow-up before relying on CSP change:** do one manual browser pass (DevTools
|
||||||
|
console) on `/signin`, OAuth consent, a Turbo navigation, dark-mode toggle, and a
|
||||||
|
WebAuthn sign-in — expect zero CSP violations. Dev is report-only so violations
|
||||||
|
surface as warnings without breaking. Fallback if style-src surprises: keep
|
||||||
|
`style-src 'unsafe-inline'`, ship script-src only.
|
||||||
|
|
||||||
|
## ☐ Remaining — MEDIUM
|
||||||
|
|
||||||
|
- [ ] **`id_token_hint` ignored at OIDC logout** — any client can redirect logout to
|
||||||
|
any other registered client's post-logout URI. Validate the hint's `aud` and
|
||||||
|
scope the redirect to that app. `app/controllers/oidc_controller.rb` (logout).
|
||||||
|
- [ ] **`offline_access` doesn't gate refresh-token issuance** — refresh tokens are
|
||||||
|
minted unconditionally; gate on the granted scope.
|
||||||
|
`app/controllers/oidc_controller.rb` (authorization_code grant, ~line 564).
|
||||||
|
- [ ] **CSP-report endpoint hardening** — unauthenticated, no rate limit / body-size
|
||||||
|
cap, logs raw CRLF (log injection). Sanitize values, cap size, rate-limit.
|
||||||
|
`app/controllers/api/csp_controller.rb`.
|
||||||
|
- [ ] **Port not stripped from `X-Forwarded-Host`** in main verify + bearer paths →
|
||||||
|
403 outages on non-standard ports (also a correctness bug). Reuse the
|
||||||
|
port-stripping done in `check_forward_auth_token`.
|
||||||
|
`app/controllers/api/forward_auth_controller.rb`.
|
||||||
|
- [ ] **WebAuthn `acr:"2"` without enforced user verification** — `user_verification:
|
||||||
|
"preferred"` lets a PIN-less key authenticate yet reports verified 2FA. Use
|
||||||
|
`"required"`, or downgrade `acr` to `"1"` when the UV flag is absent.
|
||||||
|
`app/controllers/sessions_controller.rb` (webauthn_challenge/verify),
|
||||||
|
`app/controllers/webauthn_controller.rb`.
|
||||||
|
- [ ] **`RESERVED_CLAIMS` incomplete** — missing `at_hash`/`auth_time`/`acr`; and
|
||||||
|
`ApplicationUserClaims` has no reserved-name validation (User/Group do). Could
|
||||||
|
let a custom claim overwrite a security claim. `app/services/oidc_jwt_service.rb`,
|
||||||
|
`app/models/application_user_claim.rb`.
|
||||||
|
- [ ] **`reset_session` not called on login** — defensive best practice for an IdP;
|
||||||
|
clears pre-auth session state. `app/controllers/concerns/authentication.rb`
|
||||||
|
(`start_new_session_for`).
|
||||||
|
- [x] **Hardcoded private IP `192.168.2.246`** in `config/environments/production.rb`
|
||||||
|
— removed; it was redundant with the `192.168.0.0/16` regex already in the
|
||||||
|
`CLINCH_ALLOW_INTERNAL_IPS` block.
|
||||||
|
- [ ] **CSP `form-action` widened by unvalidated `redirect_uri`** before auth — only
|
||||||
|
add to `form-action` if the client_id+redirect_uri is a registered pair.
|
||||||
|
`app/controllers/concerns/authentication.rb` (`allow_oauth_redirect_in_csp`).
|
||||||
|
- [ ] **SVG `style` attribute permits `url()`/`expression()`** — mitigated today by
|
||||||
|
`Content-Disposition: attachment`, but fragile. Sanitize CSS values or drop
|
||||||
|
`style` from the allowlist. `app/models/svg_scrubber.rb`.
|
||||||
|
- [ ] **WebAuthn error messages leak internals** — return generic errors to client,
|
||||||
|
log detail server-side. `app/controllers/sessions_controller.rb`,
|
||||||
|
`app/controllers/webauthn_controller.rb`.
|
||||||
|
- [ ] **Account enumeration via webauthn challenge** — distinguishes "user not found"
|
||||||
|
vs "no passkey". Return a uniform message. `app/controllers/sessions_controller.rb`
|
||||||
|
(`webauthn_challenge`).
|
||||||
|
- [ ] **`token_family_id` only 31 bits** (`SecureRandom.random_number(2**31)`) —
|
||||||
|
birthday collision ~46k; use a UUID/string. `app/models/oidc_refresh_token.rb`.
|
||||||
|
- [ ] **Session cookie uses sequential integer DB id** — HMAC-signed so not forgeable,
|
||||||
|
but consider a random `token` column (Rails 8 generator default).
|
||||||
|
`app/models/session.rb`, `app/controllers/concerns/authentication.rb`.
|
||||||
|
- [ ] **Login rate-limit is IP-only** — no account lockout (distributed brute force /
|
||||||
|
credential stuffing). Add failed-count + `locked_until` on users.
|
||||||
|
- [ ] **Backup-code rate limit not reset on success** and is cache-based (resets on
|
||||||
|
cache flush). Reset on success; consider DB-backed counter. `app/models/user.rb`.
|
||||||
|
|
||||||
|
## ☐ Remaining — LOW / INFO
|
||||||
|
|
||||||
|
- [ ] Public clients can't revoke their own tokens (revoke endpoint requires secret).
|
||||||
|
- [ ] Basic-auth client creds not URL-decoded per RFC 6749 §2.3.1.
|
||||||
|
- [ ] `token_hmac` columns nullable at DB level despite model `presence: true`.
|
||||||
|
- [ ] Group names allow commas → injection into `X-Remote-Groups` (false memberships
|
||||||
|
downstream). Add a format validator. `app/models/group.rb`.
|
||||||
|
- [ ] `fa_token` leaks in redirect URL / Referer / history (60s TTL, host-bound).
|
||||||
|
- [ ] Admin `domain_pattern` allows ReDoS — add a format validator.
|
||||||
|
`app/models/application.rb`.
|
||||||
|
- [ ] Forced-TOTP-setup login path can redirect-loop (`totp_required` + no TOTP).
|
||||||
|
- [ ] `complete_setup` creates an unprompted session for any authenticated user.
|
||||||
|
- [ ] Password min length only 8 — consider 12 + a max (bcrypt 72-byte truncation).
|
||||||
|
- [ ] `support_unencrypted_data: true` left enabled (TOTP secret encryption migration).
|
||||||
|
`config/initializers/active_record_encryption.rb`.
|
||||||
|
- [ ] All crypto keys derived from a single `SECRET_KEY_BASE` root — document setting
|
||||||
|
independent `ACTIVE_RECORD_ENCRYPTION_*` keys in production.
|
||||||
|
- [ ] Log injection via user `email_address` in ForwardAuth logs (strip CRLF / use
|
||||||
|
structured logging). `app/controllers/api/forward_auth_controller.rb`.
|
||||||
|
- [ ] WebAuthn RP ID is the registrable domain (cross-subdomain credential roaming) —
|
||||||
|
set `CLINCH_RP_ID` to the exact host unless roaming is intended.
|
||||||
|
`config/initializers/webauthn.rb`.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -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" %>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
%w[production development]
|
||||||
|
else
|
||||||
|
%w[production]
|
||||||
|
end
|
||||||
|
|
||||||
# Enable profiling in development/staging, disable in production unless explicitly enabled
|
# Don't send cookies, request bodies, or user IPs by default.
|
||||||
config.sentry.profiles_sample_rate = if Rails.env.production?
|
config.send_default_pii = false
|
||||||
|
|
||||||
|
# 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
|
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
|
||||||
else
|
else
|
||||||
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
|
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
|
||||||
end
|
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
|
|
||||||
|
|
||||||
# Filter sensitive parameters
|
|
||||||
if event.context[:request]
|
|
||||||
event.context[:request].reject! { |key, value|
|
|
||||||
key.to_s.match?(/password|secret|token|key|authorization/i)
|
|
||||||
}
|
|
||||||
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?
|
if defined?(Current) && Current.respond_to?(:user) && Current.user
|
||||||
}
|
event.user = (event.user || {}).merge(
|
||||||
|
id: Current.user.id,
|
||||||
|
email: Current.user.email_address,
|
||||||
|
admin: Current.user.admin?
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Enhance before_send to handle CSP events properly
|
if event.extra.is_a?(Hash)
|
||||||
config.sentry.before_send = lambda do |event, hint|
|
event.extra.reject! do |key, value|
|
||||||
# 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)
|
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) ||
|
|
||||||
value.to_s.match?(/password|secret/i)
|
|
||||||
}
|
|
||||||
end
|
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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Clinch
|
module Clinch
|
||||||
VERSION = "0.16.0"
|
VERSION = "0.16.2"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user