cloudflare_access_gate 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +155 -0
- data/LICENSE.txt +21 -0
- data/README.md +280 -0
- data/SECURITY.md +43 -0
- data/lib/cloudflare_access_gate/audit_logger.rb +58 -0
- data/lib/cloudflare_access_gate/gate.rb +183 -0
- data/lib/cloudflare_access_gate/jwks_cache.rb +146 -0
- data/lib/cloudflare_access_gate/structured_logger.rb +68 -0
- data/lib/cloudflare_access_gate/version.rb +5 -0
- data/lib/cloudflare_access_gate.rb +86 -0
- metadata +190 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b6ca1922f843d5b954725992cecf9898d6b073f62a8c2f51c189c9c4082aea07
|
|
4
|
+
data.tar.gz: 81850fd90a1bcf4484e0666ed7ef82497f00507554ff61572e52cca93093f45f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 71baf0701840236e6dbaa3a49a18f850426d13eb2da81479963bb620892e82be2dcafb86aedf7e432a8aeac1547702b68c512a05f233d98ed47940273773d543
|
|
7
|
+
data.tar.gz: 0ff41256842e598670d5e6d051abebdddbfffe2935a7be7f5ca7baf8fdf6d00472b39650e24de87123467a9e19cf7fa7cd4167b8cebf27c178cd0ca6633a9e73
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.1] - 2026-08-09
|
|
9
|
+
|
|
10
|
+
First release published to RubyGems. The library is unchanged from 0.2.0 — that
|
|
11
|
+
version was tagged in the repository but never pushed to rubygems.org, so 0.2.1
|
|
12
|
+
is the first version installable with `gem install cloudflare_access_gate`.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- A `Release` workflow that builds the gem and publishes it to RubyGems via
|
|
16
|
+
[trusted publishing](https://guides.rubygems.org/trusted-publishing/) whenever
|
|
17
|
+
a `v*` tag is pushed, so a release needs no long-lived API key on anyone's
|
|
18
|
+
machine. It refuses to publish when the tag and `VERSION` disagree, since a
|
|
19
|
+
yanked version number can never be re-pushed.
|
|
20
|
+
|
|
21
|
+
## [0.2.0] - 2026-08-05
|
|
22
|
+
|
|
23
|
+
Public-release preparation on top of 0.1.1. Everything 0.1.1 added is kept: the
|
|
24
|
+
`ensure`-logged audit record, `.protect` idempotency, the plain-Ruby blank
|
|
25
|
+
check, and the fail-closed flag specs.
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
|
|
29
|
+
- **Verify the `iss` claim.** Tokens are now checked against
|
|
30
|
+
`https://<team>.cloudflareaccess.com`, so a valid Access JWT minted by a
|
|
31
|
+
*different* Access team is rejected instead of accepted.
|
|
32
|
+
- **Deny when the team domain is unset**, as an explicit fail-closed check
|
|
33
|
+
rather than a side effect of the JWKS fetch failing.
|
|
34
|
+
- **Require `exp`, `iss`, and `aud` to be present.** The `jwt` gem's `verify_*`
|
|
35
|
+
options only check a claim when it exists, so a correctly signed token with the
|
|
36
|
+
right issuer and audience but *no* `exp` was accepted indefinitely. Now passed
|
|
37
|
+
as `required_claims`.
|
|
38
|
+
- **Reject an unusable JWKS.** A `{"keys": []}` response — or one whose entries
|
|
39
|
+
carry no key type, e.g. `{"keys":[{}]}` — is treated as a failed fetch. The
|
|
40
|
+
latter previously cached as good, evicting the last known-good key set and then
|
|
41
|
+
failing every signature check for a full TTL instead of falling back to it.
|
|
42
|
+
- **Synchronize the JwksCache construction.** `JwksCache` guards its own fetches,
|
|
43
|
+
but `Gate` memoized it with an unsynchronized `||=`, so concurrent cold-start
|
|
44
|
+
requests could each build a separate cache and run a separate 6-second fetch —
|
|
45
|
+
defeating the backoff this release added.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **Rack 3 SPEC compliance.** The 403 response used a `Content-Type` header,
|
|
50
|
+
which Rack 3 rejects outright (`Rack::Lint::LintError: uppercase character in
|
|
51
|
+
header name`). Header names are now lowercase, valid on both Rack 2 and Rack 3.
|
|
52
|
+
The response also carries an explicit `content-length`, and its headers hash is
|
|
53
|
+
a fresh mutable hash per response, so downstream middleware can add to it.
|
|
54
|
+
`spec/rack_compliance_spec.rb` runs every response through `Rack::Lint`, and CI
|
|
55
|
+
exercises Rack 2.2 and 3.x across the whole Ruby matrix.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- Clock-drift leeway of 60s for `exp`/`nbf` (configurable via `leeway:`),
|
|
60
|
+
matching the tolerance Cloudflare itself applies. Fixes spurious 403s from
|
|
61
|
+
minor clock skew. (`iat` is deliberately not covered: ruby-jwt leaves
|
|
62
|
+
`verify_iat` off and its `iat` check does not consume this leeway.)
|
|
63
|
+
- `CloudflareAccessGate::JwksCache`, extracted from `Gate`, with two new
|
|
64
|
+
availability behaviors:
|
|
65
|
+
- a 30-second backoff after a failed fetch, so a JWKS outage no longer queues
|
|
66
|
+
every request behind its own (up to 6s) HTTP timeout while holding the cache
|
|
67
|
+
mutex;
|
|
68
|
+
- a staleness grace window (`jwks_stale_grace:`, default 300s) during which a
|
|
69
|
+
previously-good key set is reused if a refresh fails. Set to `0` for strict
|
|
70
|
+
behavior.
|
|
71
|
+
- `CloudflareAccessGate::StructuredLogger` and `CloudflareAccessGate.logger`, so
|
|
72
|
+
any logger works: SemanticLogger gets native structured payloads and named
|
|
73
|
+
tags, and everything else gets the payload appended as `key=value` pairs.
|
|
74
|
+
- `logger:` option on both middlewares; `message:` and `tag_key:` on
|
|
75
|
+
`AuditLogger`; `team_domain:`/`leeway:`/`jwks_stale_grace:` on `Gate`.
|
|
76
|
+
- `.protect` forwards extra keyword arguments to `Gate`.
|
|
77
|
+
- `spec/no_rails_spec.rb`, which runs the middlewares in a subprocess where
|
|
78
|
+
requiring Rails, ActiveSupport, or SemanticLogger raises — the 0.1.1 blank-check
|
|
79
|
+
fix is now guarded against regression rather than asserted in prose.
|
|
80
|
+
- `SECURITY.md` with a private vulnerability reporting path, and
|
|
81
|
+
`CONTRIBUTING.md`.
|
|
82
|
+
- CI pins `ruby/setup-ruby` to a commit SHA rather than the mutable `v1` tag,
|
|
83
|
+
clearing the CodeQL unpinned-third-party-action finding.
|
|
84
|
+
|
|
85
|
+
### Changed (breaking)
|
|
86
|
+
|
|
87
|
+
- **Gem renamed** from `cloudflare-access-gate` to `cloudflare_access_gate`, so
|
|
88
|
+
`require:` no longer has to be spelled out in a `Gemfile`. The library name and
|
|
89
|
+
`CloudflareAccessGate` namespace are unchanged.
|
|
90
|
+
- **`semantic_logger` is no longer a runtime dependency** — it is used when the
|
|
91
|
+
host app has it loaded and ignored otherwise, which supersedes 0.1.1's
|
|
92
|
+
`>= 4, < 6` runtime bound (that bound now applies to the development
|
|
93
|
+
dependency). `jwt` is the only runtime dependency.
|
|
94
|
+
- **`required_ruby_version` lowered back to `>= 3.1`** from 0.1.1's `>= 3.4`. A
|
|
95
|
+
public gem shouldn't demand the newest Ruby, and CI now proves 3.1 works by
|
|
96
|
+
testing the full 3.1-3.4 range instead of 3.4 alone.
|
|
97
|
+
- Removed the `allowed_push_host` gemspec pin to a private registry.
|
|
98
|
+
- Internal application names, ticket IDs, and the Access team slug are out of the
|
|
99
|
+
README, CHANGELOG, gemspec, and specs ahead of the repository going public.
|
|
100
|
+
|
|
101
|
+
## [0.1.1] - 2026-07-24
|
|
102
|
+
|
|
103
|
+
### Fixed
|
|
104
|
+
- `Gate` no longer depends on ActiveSupport: the `.blank?` calls on the token,
|
|
105
|
+
audience, and team domain are replaced with a plain-Ruby check, so the
|
|
106
|
+
advertised "standalone / plain Rack, no Rails dependency" claim actually holds
|
|
107
|
+
for a non-Rails consumer (previously raised `NoMethodError`).
|
|
108
|
+
- `AuditLogger` now emits the access line in an `ensure` block, so a request
|
|
109
|
+
that raises (e.g. Redis down mid-POST) is still audited (status best-effort
|
|
110
|
+
`nil`) before the error propagates — previously the record vanished exactly
|
|
111
|
+
when it mattered.
|
|
112
|
+
- `.protect` is now idempotent: repeated calls for the same app (including
|
|
113
|
+
a development route-reload) no longer re-append the class-level middleware
|
|
114
|
+
stack. Returns the app.
|
|
115
|
+
|
|
116
|
+
### Changed
|
|
117
|
+
- `semantic_logger` dependency is bounded (`>= 4, < 6`) so a future major can't
|
|
118
|
+
break consumers whose lockfile has resolved past the pinned revision.
|
|
119
|
+
- `required_ruby_version` raised to `>= 3.4` to match what CI actually tests
|
|
120
|
+
(the RuboCop `TargetRubyVersion` and CI matrix are aligned at 3.4).
|
|
121
|
+
|
|
122
|
+
### Added
|
|
123
|
+
- Specs for the fail-closed flag semantics (non-`'false'` and unset both stay
|
|
124
|
+
enabled), the team-domain-blank fail-closed path, the 403 response body /
|
|
125
|
+
`Content-Type`, `.protect` idempotency + return value, and `ensure`-logging on
|
|
126
|
+
a raising inner app.
|
|
127
|
+
|
|
128
|
+
### Docs
|
|
129
|
+
- Corrected the provenance: one consumer held the vendored copy; the other two
|
|
130
|
+
migrated from HTTP Basic Auth / a Devise session.
|
|
131
|
+
- README: `github:` install shorthand, a per-app `session_key` example,
|
|
132
|
+
`CLOUDFLARE_ACCESS_SIDEKIQ_AUD` in the ENV table, a
|
|
133
|
+
local-dev (`ENABLE_CLOUDFLARE_GATE=false`) note, a `session_key` rationale, a
|
|
134
|
+
Releasing section, and both wiring placements (initializer or inline routes).
|
|
135
|
+
|
|
136
|
+
## [0.1.0] - 2026-07-24
|
|
137
|
+
|
|
138
|
+
### Added
|
|
139
|
+
- Initial extraction of the Cloudflare Access Sidekiq gate middleware
|
|
140
|
+
consolidated from three internal Rails applications.
|
|
141
|
+
- `CloudflareAccessGate::Gate` — Rack middleware that validates the
|
|
142
|
+
`Cf-Access-Jwt-Assertion` header against the Cloudflare Access team JWKS and a
|
|
143
|
+
configured audience. Fails closed: gate + JWT validation stay on unless a flag
|
|
144
|
+
is explicitly set to `'false'`, and a blank audience or unavailable JWKS
|
|
145
|
+
yields a 403. Hardened with JWKS HTTP timeouts, TLS peer verification, a
|
|
146
|
+
mutex-guarded JWKS cache, and denial logging (path + user).
|
|
147
|
+
- `CloudflareAccessGate::AuditLogger` — Rack middleware that logs
|
|
148
|
+
method/path/user/status for every dashboard request via SemanticLogger.
|
|
149
|
+
- `CloudflareAccessGate.protect(app, audience:, session_key:)` — installer
|
|
150
|
+
helper that wires Gate, AuditLogger, `ActionDispatch::Cookies`, and
|
|
151
|
+
`ActionDispatch::Session::CookieStore` onto a Rack app in the canonical order.
|
|
152
|
+
|
|
153
|
+
### Notes
|
|
154
|
+
- The `BLOCKED_METHODS` HTTP-method blocklist present in earlier copies was
|
|
155
|
+
intentionally dropped and is not carried by this gem.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Homebot, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# cloudflare_access_gate
|
|
2
|
+
|
|
3
|
+
Rack middleware that gates a Sidekiq (or any Rack) dashboard behind
|
|
4
|
+
[Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/),
|
|
5
|
+
plus an audit logger for every request that gets through.
|
|
6
|
+
|
|
7
|
+
It **fails closed** and has exactly one runtime dependency
|
|
8
|
+
([`jwt`](https://github.com/jwt/ruby-jwt)) — no Rails, no ActiveSupport, and no
|
|
9
|
+
logging library required. It works on **Rack 2 and Rack 3**, and it doesn't even
|
|
10
|
+
require `rack` itself: it calls no Rack APIs, only the SPEC's `call(env)`
|
|
11
|
+
contract.
|
|
12
|
+
|
|
13
|
+
It ships two plain-Rack middlewares and one Rails installer helper:
|
|
14
|
+
|
|
15
|
+
- `CloudflareAccessGate::Gate` — validates the `Cf-Access-Jwt-Assertion` header
|
|
16
|
+
(injected by the Cloudflare Access application in front of your host) against
|
|
17
|
+
your Access team's JWKS, issuer, and audience. **Fails closed.**
|
|
18
|
+
- `CloudflareAccessGate::AuditLogger` — logs `method` / `path` / `user` /
|
|
19
|
+
`status` for GET, POST, PUT, PATCH, and DELETE dashboard requests, in an
|
|
20
|
+
`ensure` so the record is emitted even if the request raises.
|
|
21
|
+
- `CloudflareAccessGate.protect(app, audience:, session_key:)` — wires both
|
|
22
|
+
middlewares plus the ActionDispatch cookie/session store onto a Rack app in
|
|
23
|
+
the canonical order (Rails hosts only). Idempotent.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem 'cloudflare_access_gate'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or straight from git, pinned to a tag:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
gem 'cloudflare_access_gate', github: 'homebotapp/cloudflare_access_gate',
|
|
35
|
+
tag: 'v0.2.1'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Rails + Sidekiq
|
|
41
|
+
|
|
42
|
+
Mount `Sidekiq::Web` **outside** your authenticated routes and register the gate
|
|
43
|
+
on it. Two placements are both valid — pick whichever your app already uses.
|
|
44
|
+
|
|
45
|
+
**A. Initializer** (e.g. `config/initializers/sidekiq_web.rb`), wired in an
|
|
46
|
+
`after_initialize` hook so the autoloader/eager-load have finished:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
require 'sidekiq/web'
|
|
50
|
+
|
|
51
|
+
Rails.application.config.after_initialize do
|
|
52
|
+
CloudflareAccessGate.protect(
|
|
53
|
+
Sidekiq::Web,
|
|
54
|
+
audience: ENV.fetch('CLOUDFLARE_ACCESS_SIDEKIQ_AUD', nil),
|
|
55
|
+
session_key: '_myapp_sidekiq_session'
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**B. Inline in `config/routes.rb`**, immediately before you `mount Sidekiq::Web`:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
CloudflareAccessGate.protect(
|
|
64
|
+
Sidekiq::Web,
|
|
65
|
+
audience: ENV.fetch('CLOUDFLARE_ACCESS_SIDEKIQ_AUD', nil),
|
|
66
|
+
session_key: '_myapp_sidekiq_session'
|
|
67
|
+
)
|
|
68
|
+
mount Sidekiq::Web => '/sidekiq'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`.protect` installs, in order:
|
|
72
|
+
|
|
73
|
+
1. `CloudflareAccessGate::Gate` (with the given `audience`)
|
|
74
|
+
2. `CloudflareAccessGate::AuditLogger`
|
|
75
|
+
3. `ActionDispatch::Cookies`
|
|
76
|
+
4. `ActionDispatch::Session::CookieStore` (keyed by `session_key`)
|
|
77
|
+
|
|
78
|
+
It is **idempotent** per app: `Sidekiq::Web`'s middleware stack is class-level,
|
|
79
|
+
so calling `.protect` twice — or on a development route-reload — would otherwise
|
|
80
|
+
re-append the whole stack. Repeated calls for the same app are a no-op, and the
|
|
81
|
+
app is returned either way.
|
|
82
|
+
|
|
83
|
+
Extra keyword arguments are forwarded to `Gate`, so
|
|
84
|
+
`CloudflareAccessGate.protect(app, audience: …, session_key: …, jwks_stale_grace: 0)`
|
|
85
|
+
works.
|
|
86
|
+
|
|
87
|
+
`ActionDispatch` must already be loaded, so `.protect` is for Rails hosts.
|
|
88
|
+
|
|
89
|
+
### `session_key`
|
|
90
|
+
|
|
91
|
+
Pass a **unique** `session_key` per app so the Sidekiq dashboard's session
|
|
92
|
+
cookie doesn't collide with another app's cookie when several dashboards share a
|
|
93
|
+
parent domain.
|
|
94
|
+
|
|
95
|
+
The cookie/session pair isn't for the gate — the gate is stateless, since
|
|
96
|
+
Cloudflare Access appends `Cf-Access-Jwt-Assertion` to every proxied request.
|
|
97
|
+
It's for Sidekiq's own CSRF protection, which hard-fails any non-safe request
|
|
98
|
+
without a `rack.session`. An `api_only` Rails app has no Cookies/Session
|
|
99
|
+
middleware of its own, so without this pair every dashboard POST (retry, kill,
|
|
100
|
+
queue pause/delete) would raise.
|
|
101
|
+
|
|
102
|
+
### Any other Rack app
|
|
103
|
+
|
|
104
|
+
`Gate` and `AuditLogger` are plain Rack — install them directly:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
use CloudflareAccessGate::Gate, audience: ENV['CLOUDFLARE_ACCESS_AUD']
|
|
108
|
+
use CloudflareAccessGate::AuditLogger
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Configuration
|
|
112
|
+
|
|
113
|
+
### ENV contract
|
|
114
|
+
|
|
115
|
+
| Variable | Default | Effect |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `ENABLE_CLOUDFLARE_GATE` | `true` | Set to exactly `'false'` to disable the gate entirely. Any other value — including `'0'` — or unset keeps it **on**. |
|
|
118
|
+
| `ENABLE_CLOUDFLARE_JWT_VALIDATION` | `true` | Set to exactly `'false'` to skip signature/issuer/audience/expiry validation (header-presence check only). Any other value keeps validation **on**. |
|
|
119
|
+
| `CLOUDFLARE_ACCESS_TEAM_DOMAIN` | _(unset)_ | Your Access team slug, used to derive the issuer `https://<team>.cloudflareaccess.com` and fetch its `/cdn-cgi/access/certs`. If blank while validation is on, requests are denied. |
|
|
120
|
+
| `CLOUDFLARE_ACCESS_SIDEKIQ_AUD` | _(unset)_ | Conventional name for the Access application AUD tag. **Not read by the gem** — your app reads it and passes it as `audience:`. |
|
|
121
|
+
|
|
122
|
+
### Gate options
|
|
123
|
+
|
|
124
|
+
| Option | Default | Meaning |
|
|
125
|
+
| --- | --- | --- |
|
|
126
|
+
| `audience:` | _(none)_ | The Access application AUD tag. A blank audience denies every request rather than skipping audience verification. |
|
|
127
|
+
| `team_domain:` | `ENV['CLOUDFLARE_ACCESS_TEAM_DOMAIN']` | Access team slug. |
|
|
128
|
+
| `leeway:` | `60` | Clock-drift allowance in seconds for `exp`/`nbf`, matching the 60s Cloudflare itself allows. |
|
|
129
|
+
| `jwks_stale_grace:` | `300` | How long a previously-good JWKS may still be used when a refresh fails. `0` denies as soon as the cache expires. |
|
|
130
|
+
| `logger:` | _(global)_ | Per-instance logger override. |
|
|
131
|
+
|
|
132
|
+
### AuditLogger options
|
|
133
|
+
|
|
134
|
+
| Option | Default | Meaning |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| `message:` | `'Sidekiq dashboard access'` | Log message for each audited request. |
|
|
137
|
+
| `tag_key:` | `:sidekiq_user` | SemanticLogger named-tag key for the authenticated user. |
|
|
138
|
+
| `logger:` | _(global)_ | Per-instance logger override. |
|
|
139
|
+
|
|
140
|
+
### Logging
|
|
141
|
+
|
|
142
|
+
Both middlewares log through `CloudflareAccessGate::StructuredLogger`, which
|
|
143
|
+
takes any logger you give it:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
CloudflareAccessGate.logger = Rails.logger
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
If you don't set one, [SemanticLogger](https://github.com/reidmorrison/semantic_logger)
|
|
150
|
+
is used when your app already has it loaded (giving per-class named loggers,
|
|
151
|
+
structured payloads, and a `sidekiq_user` named tag around each request), and a
|
|
152
|
+
stdlib `Logger.new($stdout)` otherwise. For non-SemanticLogger loggers the
|
|
153
|
+
structured payload is appended to the message as `key=value` pairs rather than
|
|
154
|
+
dropped, and tagging is skipped.
|
|
155
|
+
|
|
156
|
+
### Local development
|
|
157
|
+
|
|
158
|
+
There is no Cloudflare Access in front of your local dashboard, so no
|
|
159
|
+
`Cf-Access-Jwt-Assertion` header is present and the fail-closed gate returns
|
|
160
|
+
**403** for every `/sidekiq` request. To use the dashboard locally, set:
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
ENABLE_CLOUDFLARE_GATE=false
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
(Only the literal string `false` disables it.)
|
|
167
|
+
|
|
168
|
+
## Security behavior
|
|
169
|
+
|
|
170
|
+
### Fail-closed
|
|
171
|
+
|
|
172
|
+
- Missing/blank `Cf-Access-Jwt-Assertion` header → **403**.
|
|
173
|
+
- JWT validation on + blank audience → **403** (never verifies "no audience").
|
|
174
|
+
- JWT validation on + blank team domain → **403** (no issuer to verify against).
|
|
175
|
+
- JWKS endpoint unavailable, non-JSON, or containing no usable keys → **403**.
|
|
176
|
+
- Invalid signature, wrong issuer, wrong audience, expired token, or algorithm
|
|
177
|
+
confusion (e.g. HS256 with the public key) → **403**.
|
|
178
|
+
- A token **missing** `exp`, `iss`, or `aud` → **403**. The `jwt` gem's
|
|
179
|
+
`verify_*` options only check a claim that is present, so these are required
|
|
180
|
+
explicitly; otherwise a correctly signed token with no `exp` would never
|
|
181
|
+
expire.
|
|
182
|
+
- Flags are strict: only the literal string `'false'` disables the gate / JWT
|
|
183
|
+
validation. Any other value — including `'0'` or an unset var — leaves them on.
|
|
184
|
+
|
|
185
|
+
### Hardening
|
|
186
|
+
|
|
187
|
+
- JWKS fetches use short open/read timeouts and `OpenSSL::SSL::VERIFY_PEER`.
|
|
188
|
+
- The JWKS cache is mutex-guarded and refreshed after a 10-minute TTL.
|
|
189
|
+
- A failed JWKS fetch starts a 30-second backoff, so a JWKS outage can't queue
|
|
190
|
+
every request behind its own HTTP timeout.
|
|
191
|
+
- Denials are logged with the request path and the Cloudflare-authenticated user.
|
|
192
|
+
|
|
193
|
+
### The staleness trade-off
|
|
194
|
+
|
|
195
|
+
If a JWKS refresh fails, the last known-good key set is reused for up to
|
|
196
|
+
`jwks_stale_grace` seconds (default 300) past the cache TTL. Access signing keys
|
|
197
|
+
rotate on the order of weeks, so a few minutes of staleness is a much smaller
|
|
198
|
+
risk than locking every operator out of the dashboard during a transient
|
|
199
|
+
failure. If you would rather deny than serve stale keys, set
|
|
200
|
+
`jwks_stale_grace: 0`.
|
|
201
|
+
|
|
202
|
+
### What this middleware does *not* do
|
|
203
|
+
|
|
204
|
+
It verifies that a request carries a valid Access JWT for **your** application.
|
|
205
|
+
It does not implement per-user authorization — which identities may reach the
|
|
206
|
+
app is decided by your Cloudflare Access policy, not by this gem. It is also not
|
|
207
|
+
a substitute for keeping the origin unreachable except through Cloudflare; an
|
|
208
|
+
attacker who can reach your origin directly bypasses Access entirely, so pair
|
|
209
|
+
this with origin locking (Tunnel, mTLS, or IP allowlisting).
|
|
210
|
+
|
|
211
|
+
To report a vulnerability, see [SECURITY.md](SECURITY.md).
|
|
212
|
+
|
|
213
|
+
## Rack compatibility
|
|
214
|
+
|
|
215
|
+
Supported on **Rack 2.x and Rack 3.x**, and on plain Rack-compatible servers with
|
|
216
|
+
no `rack` gem loaded at all — the middlewares implement `call(env)` and use
|
|
217
|
+
nothing from Rack's API.
|
|
218
|
+
|
|
219
|
+
Responses use **lowercase header names** (`content-type`, not `Content-Type`).
|
|
220
|
+
Rack 3 requires this — an uppercase header name is a `Rack::Lint::LintError`
|
|
221
|
+
there — and Rack 2 accepts it, since HTTP header names are case-insensitive. The
|
|
222
|
+
403 also carries an explicit `content-length`, and its headers hash is a fresh
|
|
223
|
+
mutable hash per response so downstream middleware can add to it.
|
|
224
|
+
|
|
225
|
+
`spec/rack_compliance_spec.rb` runs every response this gem produces through
|
|
226
|
+
`Rack::Lint`, the SPEC in executable form. CI runs it against both major Rack
|
|
227
|
+
versions across Ruby 3.1–3.4.
|
|
228
|
+
|
|
229
|
+
## Development
|
|
230
|
+
|
|
231
|
+
```sh
|
|
232
|
+
bundle install
|
|
233
|
+
bundle exec rspec # test suite
|
|
234
|
+
bundle exec rubocop # lint
|
|
235
|
+
bundle exec rake # both (default task)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
To test against a specific Rack major version, as CI does:
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
RACK_VERSION=2.2 bundle install && RACK_VERSION=2.2 bundle exec rspec
|
|
242
|
+
RACK_VERSION=3 bundle install && RACK_VERSION=3 bundle exec rspec
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`spec/no_rails_spec.rb` runs the middlewares in a subprocess where requiring
|
|
246
|
+
Rails, ActiveSupport, or SemanticLogger raises, which is what keeps the
|
|
247
|
+
"no Rails dependency" claim honest. CI runs rspec on Ruby 3.1–3.4 against Rack
|
|
248
|
+
2.2 and 3.x, plus rubocop.
|
|
249
|
+
|
|
250
|
+
Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
251
|
+
|
|
252
|
+
## Releasing
|
|
253
|
+
|
|
254
|
+
`.github/workflows/release.yml` publishes to RubyGems over
|
|
255
|
+
[trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no API
|
|
256
|
+
key is stored in the repository or on a maintainer's machine.
|
|
257
|
+
|
|
258
|
+
1. Open a PR bumping `CloudflareAccessGate::VERSION` in
|
|
259
|
+
`lib/cloudflare_access_gate/version.rb`, updating the version assertion in
|
|
260
|
+
`spec/cloudflare_access_gate_spec.rb`, and adding a `CHANGELOG.md` entry.
|
|
261
|
+
`main` is protected, so this can't be pushed directly.
|
|
262
|
+
2. Merge it once CI is green.
|
|
263
|
+
3. Create and push an annotated tag on the merge commit:
|
|
264
|
+
`git tag -a vX.Y.Z -m "…" && git push origin vX.Y.Z`.
|
|
265
|
+
4. The Release workflow verifies the tag matches `VERSION`, builds the gem,
|
|
266
|
+
pushes it to RubyGems, and creates the GitHub Release. A mismatch fails the
|
|
267
|
+
job before anything is published — a yanked version number can never be
|
|
268
|
+
reused.
|
|
269
|
+
5. For consumers pinned by git ref rather than the published gem, update the
|
|
270
|
+
`tag:` in their `Gemfile` and re-resolve:
|
|
271
|
+
`bundle update cloudflare_access_gate`.
|
|
272
|
+
|
|
273
|
+
Don't run `rake release` yourself: it pushes the release commit and tag straight
|
|
274
|
+
to `main`, which branch protection rejects. The workflow does invoke it, but
|
|
275
|
+
because the tag already exists when it runs, Bundler skips its source-control
|
|
276
|
+
push and goes straight to publishing.
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
[MIT](LICENSE.txt) © Homebot, Inc.
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are released for the latest minor version. Until 1.0, that means
|
|
6
|
+
the most recent `0.x` release.
|
|
7
|
+
|
|
8
|
+
## Reporting a vulnerability
|
|
9
|
+
|
|
10
|
+
**Please do not open a public GitHub issue for a security vulnerability.**
|
|
11
|
+
|
|
12
|
+
Report it privately through either channel:
|
|
13
|
+
|
|
14
|
+
- GitHub's [private vulnerability reporting](https://github.com/homebotapp/cloudflare_access_gate/security/advisories/new)
|
|
15
|
+
(Security → Report a vulnerability), or
|
|
16
|
+
- email **engineering@homebot.ai** with `cloudflare_access_gate` in the subject.
|
|
17
|
+
|
|
18
|
+
Please include the affected version, a description of the impact, and enough
|
|
19
|
+
detail to reproduce — a failing spec or a minimal Rack app is ideal.
|
|
20
|
+
|
|
21
|
+
We aim to acknowledge a report within 3 business days and to ship a fix or share
|
|
22
|
+
a mitigation timeline within 30 days. We'll credit you in the advisory and
|
|
23
|
+
CHANGELOG unless you'd rather stay anonymous.
|
|
24
|
+
|
|
25
|
+
## Scope
|
|
26
|
+
|
|
27
|
+
In scope — anything that lets a request reach the protected app without a valid
|
|
28
|
+
Cloudflare Access JWT for the configured audience, for example:
|
|
29
|
+
|
|
30
|
+
- accepting a token with a wrong or absent signature, issuer, audience, or expiry
|
|
31
|
+
- a configuration or failure mode that opens the gate instead of closing it
|
|
32
|
+
- cache poisoning of the JWKS, or accepting keys from another Access team
|
|
33
|
+
|
|
34
|
+
Out of scope:
|
|
35
|
+
|
|
36
|
+
- Cloudflare Access policy misconfiguration (which identities are allowed is
|
|
37
|
+
decided in your Access policy, not by this gem)
|
|
38
|
+
- an origin reachable without going through Cloudflare — see "What this
|
|
39
|
+
middleware does *not* do" in the README
|
|
40
|
+
- setting `ENABLE_CLOUDFLARE_GATE=false` or `ENABLE_CLOUDFLARE_JWT_VALIDATION=false`,
|
|
41
|
+
which are documented, deliberate off switches
|
|
42
|
+
- vulnerabilities in the `jwt` gem itself (report those to
|
|
43
|
+
[ruby-jwt](https://github.com/jwt/ruby-jwt/security))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CloudflareAccessGate
|
|
4
|
+
# Rack middleware that audit-logs every request to the gated dashboard,
|
|
5
|
+
# recording the HTTP method, path, Cloudflare-authenticated user email, and
|
|
6
|
+
# response status. Install it behind Gate so only authorized requests are
|
|
7
|
+
# logged.
|
|
8
|
+
#
|
|
9
|
+
# Like Gate, this is plain Rack: no Rails, ActiveSupport, or logging library is
|
|
10
|
+
# required at runtime.
|
|
11
|
+
class AuditLogger
|
|
12
|
+
LOGGED_METHODS = %w[GET POST PUT PATCH DELETE].freeze
|
|
13
|
+
|
|
14
|
+
DEFAULT_MESSAGE = 'Sidekiq dashboard access'
|
|
15
|
+
DEFAULT_TAG_KEY = :sidekiq_user
|
|
16
|
+
|
|
17
|
+
# Options:
|
|
18
|
+
# logger: per-instance logger override
|
|
19
|
+
# message: log message for each audited request
|
|
20
|
+
# tag_key: SemanticLogger named-tag key for the authenticated user
|
|
21
|
+
def initialize(app, options = {})
|
|
22
|
+
@app = app
|
|
23
|
+
@logger = options[:logger] && StructuredLogger.wrap(options[:logger])
|
|
24
|
+
@message = options.fetch(:message, DEFAULT_MESSAGE)
|
|
25
|
+
@tag_key = options.fetch(:tag_key, DEFAULT_TAG_KEY)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(env)
|
|
29
|
+
request_method = env['REQUEST_METHOD']
|
|
30
|
+
|
|
31
|
+
return @app.call(env) unless LOGGED_METHODS.include?(request_method)
|
|
32
|
+
|
|
33
|
+
email = env['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] || 'unknown'
|
|
34
|
+
status = nil
|
|
35
|
+
|
|
36
|
+
# Logged in an `ensure` so a raising request is still audited.
|
|
37
|
+
logger.tagged(@tag_key => email) do
|
|
38
|
+
response = @app.call(env)
|
|
39
|
+
status = response.first
|
|
40
|
+
response
|
|
41
|
+
ensure
|
|
42
|
+
logger.info(
|
|
43
|
+
@message,
|
|
44
|
+
method: request_method,
|
|
45
|
+
path: env['PATH_INFO'],
|
|
46
|
+
user: email,
|
|
47
|
+
status: status
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def logger
|
|
55
|
+
@logger ||= CloudflareAccessGate.logger_for('CloudflareAccessGate::AuditLogger')
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
|
|
5
|
+
module CloudflareAccessGate
|
|
6
|
+
# Rack middleware that gates a dashboard (typically Sidekiq::Web) behind
|
|
7
|
+
# Cloudflare Access. It reads the `Cf-Access-Jwt-Assertion` header injected by
|
|
8
|
+
# the Cloudflare Access application in front of the host and, when JWT
|
|
9
|
+
# validation is enabled, verifies the token's signature (against the team's
|
|
10
|
+
# JWKS), issuer, audience, and expiry before letting the request through.
|
|
11
|
+
#
|
|
12
|
+
# Everything is fail-closed: the gate stays on and rejects requests unless a
|
|
13
|
+
# flag is explicitly set to the string 'false', and a missing audience, missing
|
|
14
|
+
# team domain, or unavailable JWKS results in a 403 rather than an open door.
|
|
15
|
+
#
|
|
16
|
+
# ENV contract:
|
|
17
|
+
# ENABLE_CLOUDFLARE_GATE - 'false' disables the gate entirely
|
|
18
|
+
# ENABLE_CLOUDFLARE_JWT_VALIDATION - 'false' skips signature/iss/aud checks
|
|
19
|
+
# CLOUDFLARE_ACCESS_TEAM_DOMAIN - your Cloudflare Access team slug
|
|
20
|
+
# The audience is passed as a constructor option, not read from ENV.
|
|
21
|
+
#
|
|
22
|
+
# This class is plain Rack: no Rails, ActiveSupport, or logging library is
|
|
23
|
+
# required at runtime.
|
|
24
|
+
class Gate
|
|
25
|
+
# Clock drift allowance for exp/nbf, matching the 60s Cloudflare itself
|
|
26
|
+
# allows when validating tokens. (`iat` is not verified: ruby-jwt leaves
|
|
27
|
+
# verify_iat off by default and its iat check ignores this leeway.)
|
|
28
|
+
DEFAULT_LEEWAY = 60 # seconds
|
|
29
|
+
|
|
30
|
+
# `verify_*` options only check a claim when it is present, so a correctly
|
|
31
|
+
# signed token that simply omits `exp` would otherwise never expire.
|
|
32
|
+
REQUIRED_CLAIMS = %w[exp iss aud].freeze
|
|
33
|
+
|
|
34
|
+
FORBIDDEN_BODY = 'Forbidden'
|
|
35
|
+
|
|
36
|
+
# Header names are lowercase because the Rack 3 SPEC requires it ("uppercase
|
|
37
|
+
# character in header name" is a Rack::Lint error there). Rack 2 accepts
|
|
38
|
+
# lowercase too — HTTP header names are case-insensitive — so one spelling
|
|
39
|
+
# is correct on both. `content-length` is set explicitly so the response is
|
|
40
|
+
# complete for servers that don't add it.
|
|
41
|
+
FORBIDDEN_HEADERS = {
|
|
42
|
+
'content-type' => 'text/plain',
|
|
43
|
+
'content-length' => FORBIDDEN_BODY.bytesize.to_s
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
attr_reader :audience, :team_domain
|
|
47
|
+
|
|
48
|
+
# Options:
|
|
49
|
+
# audience: (required in practice) the Access application AUD tag
|
|
50
|
+
# team_domain: Access team slug; defaults to ENV
|
|
51
|
+
# leeway: clock drift allowance in seconds
|
|
52
|
+
# jwks_stale_grace: seconds a stale JWKS may be reused on refresh failure
|
|
53
|
+
# logger: per-instance logger override
|
|
54
|
+
# clock: callable returning monotonic seconds (for tests)
|
|
55
|
+
def initialize(app, options = {})
|
|
56
|
+
@app = app
|
|
57
|
+
@audience = options[:audience]
|
|
58
|
+
@team_domain = options.fetch(:team_domain) { ENV.fetch('CLOUDFLARE_ACCESS_TEAM_DOMAIN', nil) }
|
|
59
|
+
@leeway = options.fetch(:leeway, DEFAULT_LEEWAY)
|
|
60
|
+
@logger = options[:logger] && StructuredLogger.wrap(options[:logger])
|
|
61
|
+
@jwks_cache_mutex = Mutex.new
|
|
62
|
+
@jwks_options = {
|
|
63
|
+
stale_grace: options.fetch(:jwks_stale_grace, JwksCache::DEFAULT_STALE_GRACE),
|
|
64
|
+
clock: options.fetch(:clock, JwksCache::MONOTONIC_CLOCK)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def call(env)
|
|
69
|
+
return @app.call(env) unless self.class.enabled?
|
|
70
|
+
|
|
71
|
+
token = env['HTTP_CF_ACCESS_JWT_ASSERTION']
|
|
72
|
+
return forbidden('Missing access token', env) if blank?(token)
|
|
73
|
+
|
|
74
|
+
return forbidden('Invalid access token', env) unless token_acceptable?(token)
|
|
75
|
+
|
|
76
|
+
@app.call(env)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Fail closed: the gate stays ON unless explicitly disabled with 'false'.
|
|
80
|
+
def self.enabled?
|
|
81
|
+
ENV.fetch('ENABLE_CLOUDFLARE_GATE', 'true') != 'false'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Fail closed: JWT validation stays ON unless explicitly disabled with 'false'.
|
|
85
|
+
def self.jwt_validation_enabled?
|
|
86
|
+
ENV.fetch('ENABLE_CLOUDFLARE_JWT_VALIDATION', 'true') != 'false'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# When JWT validation is switched off, the presence of the header (already
|
|
92
|
+
# checked by the caller) is all that is required.
|
|
93
|
+
def token_acceptable?(token)
|
|
94
|
+
return true unless self.class.jwt_validation_enabled?
|
|
95
|
+
|
|
96
|
+
!verify_token(token).nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the decoded payload, or nil if the token must be rejected.
|
|
100
|
+
def verify_token(token)
|
|
101
|
+
return nil unless verifiable?
|
|
102
|
+
|
|
103
|
+
jwks = jwks_cache.fetch
|
|
104
|
+
return nil unless jwks
|
|
105
|
+
|
|
106
|
+
decode(token, jwks)
|
|
107
|
+
rescue JWT::DecodeError => e
|
|
108
|
+
logger.warn('Cloudflare Access JWT verification failed', error: e.message, error_class: e.class.name)
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def decode(token, jwks)
|
|
113
|
+
JWT.decode(
|
|
114
|
+
token,
|
|
115
|
+
nil,
|
|
116
|
+
true,
|
|
117
|
+
algorithms: ['RS256'],
|
|
118
|
+
jwks: jwks,
|
|
119
|
+
aud: audience,
|
|
120
|
+
verify_aud: true,
|
|
121
|
+
iss: issuer,
|
|
122
|
+
verify_iss: true,
|
|
123
|
+
verify_expiration: true,
|
|
124
|
+
required_claims: REQUIRED_CLAIMS,
|
|
125
|
+
leeway: @leeway
|
|
126
|
+
).first
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Both of these must be configured for verification to mean anything, so a
|
|
130
|
+
# blank value denies rather than silently skipping a check.
|
|
131
|
+
def verifiable?
|
|
132
|
+
# A nil/blank audience makes the jwt gem skip aud verification, which would
|
|
133
|
+
# let any valid Cloudflare Access JWT (from any app) through.
|
|
134
|
+
if blank?(audience)
|
|
135
|
+
logger.error('Cloudflare Access JWT not verified: audience not configured')
|
|
136
|
+
return false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The issuer and the JWKS URL are both derived from the team domain.
|
|
140
|
+
if blank?(team_domain)
|
|
141
|
+
logger.error('Cloudflare Access JWT not verified: CLOUDFLARE_ACCESS_TEAM_DOMAIN unset')
|
|
142
|
+
return false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def issuer
|
|
149
|
+
"https://#{team_domain}.cloudflareaccess.com"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Synchronized: JwksCache guards its own fetches, but an unsynchronized
|
|
153
|
+
# `||=` here lets concurrent cold-start requests each build their own cache
|
|
154
|
+
# and each run a separate fetch, which is what the backoff exists to prevent.
|
|
155
|
+
def jwks_cache
|
|
156
|
+
@jwks_cache_mutex.synchronize do
|
|
157
|
+
@jwks_cache ||= JwksCache.new(
|
|
158
|
+
certs_url: "#{issuer}/cdn-cgi/access/certs",
|
|
159
|
+
logger: logger,
|
|
160
|
+
**@jwks_options
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def forbidden(message, env)
|
|
166
|
+
logger.warn('Cloudflare Access gate denied request',
|
|
167
|
+
reason: message,
|
|
168
|
+
path: env['PATH_INFO'],
|
|
169
|
+
user: env['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'])
|
|
170
|
+
[403, FORBIDDEN_HEADERS.dup, [FORBIDDEN_BODY]]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Stands in for ActiveSupport's `Object#blank?` so the middleware works in a
|
|
174
|
+
# plain Rack app with no Rails loaded.
|
|
175
|
+
def blank?(value)
|
|
176
|
+
value.nil? || value.to_s.strip.empty?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def logger
|
|
180
|
+
@logger ||= CloudflareAccessGate.logger_for('CloudflareAccessGate::Gate')
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
|
|
7
|
+
module CloudflareAccessGate
|
|
8
|
+
# Fetches and caches the Cloudflare Access team's JWKS (public signing keys).
|
|
9
|
+
#
|
|
10
|
+
# `#fetch` returns a usable key set or nil; nil means the caller must deny the
|
|
11
|
+
# request. Three behaviors matter for a gate that has to stay both closed and
|
|
12
|
+
# available:
|
|
13
|
+
#
|
|
14
|
+
# * **TTL** — a good key set is served from memory for CACHE_TTL seconds.
|
|
15
|
+
# * **Error backoff** — fetches happen under a mutex, so without a cooldown
|
|
16
|
+
# every request during a JWKS outage would queue behind its own (up to 6s)
|
|
17
|
+
# HTTP timeout, hanging the dashboard. After a failure, Cloudflare isn't
|
|
18
|
+
# contacted again for ERROR_BACKOFF seconds.
|
|
19
|
+
# * **Staleness grace** — if a refresh fails, the last known-good key set is
|
|
20
|
+
# reused for up to `stale_grace` seconds past the TTL. Access signing keys
|
|
21
|
+
# rotate on the order of weeks, so a few minutes of staleness is a far
|
|
22
|
+
# smaller risk than locking every operator out during a transient failure.
|
|
23
|
+
# Pass `stale_grace: 0` for strict behavior.
|
|
24
|
+
class JwksCache
|
|
25
|
+
CACHE_TTL = 600 # seconds a successfully fetched JWKS is served as fresh
|
|
26
|
+
ERROR_BACKOFF = 30 # seconds to wait after a failed fetch before retrying
|
|
27
|
+
DEFAULT_STALE_GRACE = 300 # seconds a stale JWKS may be reused past the TTL
|
|
28
|
+
HTTP_OPEN_TIMEOUT = 3 # seconds
|
|
29
|
+
HTTP_READ_TIMEOUT = 3 # seconds
|
|
30
|
+
|
|
31
|
+
MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
32
|
+
|
|
33
|
+
def initialize(certs_url:, logger:, stale_grace: DEFAULT_STALE_GRACE, clock: MONOTONIC_CLOCK)
|
|
34
|
+
@certs_url = certs_url
|
|
35
|
+
@logger = logger
|
|
36
|
+
@stale_grace = stale_grace
|
|
37
|
+
@clock = clock
|
|
38
|
+
|
|
39
|
+
@jwks = nil
|
|
40
|
+
@fetched_at = nil
|
|
41
|
+
@failed_at = nil
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the JWKS to verify against, or nil if none can be obtained.
|
|
46
|
+
def fetch
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
return @jwks if fresh?
|
|
49
|
+
return stale if backing_off?
|
|
50
|
+
|
|
51
|
+
jwks = request
|
|
52
|
+
return remember(jwks) if jwks
|
|
53
|
+
|
|
54
|
+
@failed_at = now
|
|
55
|
+
stale
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
attr_reader :logger
|
|
62
|
+
|
|
63
|
+
def remember(jwks)
|
|
64
|
+
@jwks = jwks
|
|
65
|
+
@fetched_at = now
|
|
66
|
+
@failed_at = nil
|
|
67
|
+
@jwks
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fresh?
|
|
71
|
+
!@jwks.nil? && !@fetched_at.nil? && (now - @fetched_at) < CACHE_TTL
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# True while the JWKS endpoint is in its post-failure cooldown.
|
|
75
|
+
def backing_off?
|
|
76
|
+
!@failed_at.nil? && (now - @failed_at) < ERROR_BACKOFF
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The last known-good JWKS, if it is still inside the staleness grace window.
|
|
80
|
+
def stale
|
|
81
|
+
return nil if @jwks.nil? || @fetched_at.nil?
|
|
82
|
+
|
|
83
|
+
age = now - @fetched_at
|
|
84
|
+
if age > (CACHE_TTL + @stale_grace)
|
|
85
|
+
logger.error('Cloudflare JWKS stale beyond grace period; denying requests', age_seconds: age.round)
|
|
86
|
+
return nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
logger.warn('Serving stale Cloudflare JWKS after refresh failure', age_seconds: age.round)
|
|
90
|
+
@jwks
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns the parsed JWKS on success, nil on any failure.
|
|
94
|
+
def request
|
|
95
|
+
uri = URI(@certs_url)
|
|
96
|
+
response = http_get(uri)
|
|
97
|
+
|
|
98
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
99
|
+
logger.error('Cloudflare JWKS fetch failed', http_status: response&.code)
|
|
100
|
+
return nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
parse(response.body)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
logger.error('Failed to fetch Cloudflare JWKS', error: e.message, error_class: e.class.name)
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def http_get(uri)
|
|
110
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
111
|
+
http.use_ssl = true
|
|
112
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
113
|
+
http.open_timeout = HTTP_OPEN_TIMEOUT
|
|
114
|
+
http.read_timeout = HTTP_READ_TIMEOUT
|
|
115
|
+
http.get(uri.request_uri)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse(body)
|
|
119
|
+
jwks = JSON.parse(body)
|
|
120
|
+
keys = jwks.is_a?(Hash) ? jwks['keys'] : nil
|
|
121
|
+
|
|
122
|
+
# A key set that is empty — or whose entries carry no key type — would be
|
|
123
|
+
# cached as good, discarding the last known-good set and then failing every
|
|
124
|
+
# signature check for a full TTL. Treat it as a failed fetch instead, so the
|
|
125
|
+
# staleness grace window can do its job.
|
|
126
|
+
unless keys.is_a?(Array) && keys.any? { |key| usable_key?(key) }
|
|
127
|
+
logger.error('Cloudflare JWKS response contained no usable keys')
|
|
128
|
+
return nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
jwks
|
|
132
|
+
rescue JSON::ParserError => e
|
|
133
|
+
logger.error('Cloudflare JWKS response was not valid JSON', error: e.message)
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Minimum bar for a JWKS entry the jwt gem could actually verify against.
|
|
138
|
+
def usable_key?(key)
|
|
139
|
+
key.is_a?(Hash) && !key['kty'].to_s.strip.empty?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def now
|
|
143
|
+
@clock.call
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module CloudflareAccessGate
|
|
6
|
+
# Thin adapter that lets this gem emit structured key/value log payloads
|
|
7
|
+
# without depending on any particular logging library.
|
|
8
|
+
#
|
|
9
|
+
# SemanticLogger accepts a payload natively (`logger.warn('msg', key: 'v')`);
|
|
10
|
+
# the stdlib `Logger`, `Rails.logger`, lograge, and friends do not. This
|
|
11
|
+
# wrapper detects a SemanticLogger target and passes the payload through,
|
|
12
|
+
# and otherwise appends a `key=value` suffix to the message so no log data is
|
|
13
|
+
# lost.
|
|
14
|
+
#
|
|
15
|
+
# Wrap any logger-ish object: it only needs to respond to the standard
|
|
16
|
+
# `debug`/`info`/`warn`/`error` severity methods.
|
|
17
|
+
class StructuredLogger
|
|
18
|
+
LEVELS = %i[debug info warn error].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :target
|
|
21
|
+
|
|
22
|
+
# Wraps `logger` unless it is already wrapped.
|
|
23
|
+
def self.wrap(logger)
|
|
24
|
+
logger.is_a?(self) ? logger : new(logger)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(target)
|
|
28
|
+
@target = target
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
LEVELS.each do |level|
|
|
32
|
+
define_method(level) do |message, **payload|
|
|
33
|
+
emit(level, message, payload)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Attaches `tags` to every log line emitted inside the block.
|
|
38
|
+
#
|
|
39
|
+
# Only SemanticLogger supports this; for every other logger the block is
|
|
40
|
+
# simply yielded (the same key/values are still emitted in the payload of
|
|
41
|
+
# the log line itself, so nothing is silently dropped).
|
|
42
|
+
def tagged(**tags, &block)
|
|
43
|
+
return block.call if tags.empty? || !structured?
|
|
44
|
+
|
|
45
|
+
target.tagged(**tags, &block)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def emit(level, message, payload)
|
|
51
|
+
if structured?
|
|
52
|
+
target.public_send(level, message, payload)
|
|
53
|
+
else
|
|
54
|
+
target.public_send(level, merged_message(message, payload))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def structured?
|
|
59
|
+
defined?(::SemanticLogger::Base) && target.is_a?(::SemanticLogger::Base)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def merged_message(message, payload)
|
|
63
|
+
return message if payload.empty?
|
|
64
|
+
|
|
65
|
+
"#{message} (#{payload.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')})"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cloudflare_access_gate/version'
|
|
4
|
+
require 'cloudflare_access_gate/structured_logger'
|
|
5
|
+
require 'cloudflare_access_gate/jwks_cache'
|
|
6
|
+
require 'cloudflare_access_gate/gate'
|
|
7
|
+
require 'cloudflare_access_gate/audit_logger'
|
|
8
|
+
|
|
9
|
+
# Rack middleware that gates a dashboard (typically Sidekiq::Web) behind
|
|
10
|
+
# Cloudflare Access, plus an audit logger for the requests that get through.
|
|
11
|
+
# See the README for the ENV contract and security notes.
|
|
12
|
+
module CloudflareAccessGate
|
|
13
|
+
@installed = {}
|
|
14
|
+
@install_mutex = Mutex.new
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Logger used by both middlewares. Assign anything that responds to
|
|
18
|
+
# `debug`/`info`/`warn`/`error` — SemanticLogger, a stdlib `Logger`,
|
|
19
|
+
# `Rails.logger`, and so on:
|
|
20
|
+
#
|
|
21
|
+
# CloudflareAccessGate.logger = Rails.logger
|
|
22
|
+
#
|
|
23
|
+
# When left unset (`nil`), SemanticLogger is used if the host app has it
|
|
24
|
+
# loaded (giving per-class named loggers and tagged output), and a stdlib
|
|
25
|
+
# `Logger.new($stdout)` otherwise.
|
|
26
|
+
attr_accessor :logger
|
|
27
|
+
|
|
28
|
+
# Returns the logger to use for `name`, wrapped so structured payloads work
|
|
29
|
+
# regardless of the underlying logging library.
|
|
30
|
+
def logger_for(name)
|
|
31
|
+
StructuredLogger.wrap(@logger || default_logger(name))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def default_logger(name)
|
|
37
|
+
return ::SemanticLogger[name] if defined?(::SemanticLogger)
|
|
38
|
+
|
|
39
|
+
stdout_logger
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stdout_logger
|
|
43
|
+
@stdout_logger ||= ::Logger.new($stdout)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Wire the full Cloudflare Access protection stack onto a Rack app in the
|
|
48
|
+
# canonical order. Typically called on Sidekiq::Web from an initializer:
|
|
49
|
+
#
|
|
50
|
+
# CloudflareAccessGate.protect(
|
|
51
|
+
# Sidekiq::Web,
|
|
52
|
+
# audience: ENV['CLOUDFLARE_ACCESS_SIDEKIQ_AUD'],
|
|
53
|
+
# session_key: '_myapp_sidekiq_session'
|
|
54
|
+
# )
|
|
55
|
+
#
|
|
56
|
+
# This installs, in order:
|
|
57
|
+
# 1. Gate - rejects requests without a valid Cf-Access JWT
|
|
58
|
+
# 2. AuditLogger - logs who accessed the dashboard
|
|
59
|
+
# 3. ActionDispatch::Cookies
|
|
60
|
+
# 4. ActionDispatch::Session::CookieStore(key: session_key)
|
|
61
|
+
#
|
|
62
|
+
# ActionDispatch (from Rails/actionpack) must be loaded before this is
|
|
63
|
+
# called, so `.protect` is for Rails hosts only. Gate and AuditLogger
|
|
64
|
+
# themselves are plain Rack and carry no Rails dependency — outside Rails,
|
|
65
|
+
# install them directly with `app.use`.
|
|
66
|
+
#
|
|
67
|
+
# Extra keyword arguments are forwarded to Gate (e.g. `team_domain:`,
|
|
68
|
+
# `jwks_stale_grace:`).
|
|
69
|
+
#
|
|
70
|
+
# Idempotent: the middleware stack is class-level, so re-calling would
|
|
71
|
+
# otherwise re-append it. Returns the app.
|
|
72
|
+
def self.protect(app, audience:, session_key:, **gate_options)
|
|
73
|
+
@install_mutex.synchronize do
|
|
74
|
+
return app if @installed[app]
|
|
75
|
+
|
|
76
|
+
app.use Gate, **gate_options, audience: audience
|
|
77
|
+
app.use AuditLogger
|
|
78
|
+
app.use ActionDispatch::Cookies
|
|
79
|
+
app.use ActionDispatch::Session::CookieStore, key: session_key
|
|
80
|
+
|
|
81
|
+
@installed[app] = true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
app
|
|
85
|
+
end
|
|
86
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: cloudflare_access_gate
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Homebot Infrastructure
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: jwt
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: actionpack
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '6.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '6.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rack
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rspec
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rubocop
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '1.0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '1.0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: semantic_logger
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '4'
|
|
103
|
+
- - "<"
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '6'
|
|
106
|
+
type: :development
|
|
107
|
+
prerelease: false
|
|
108
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: '4'
|
|
113
|
+
- - "<"
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '6'
|
|
116
|
+
- !ruby/object:Gem::Dependency
|
|
117
|
+
name: timecop
|
|
118
|
+
requirement: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - "~>"
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: '0.9'
|
|
123
|
+
type: :development
|
|
124
|
+
prerelease: false
|
|
125
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
126
|
+
requirements:
|
|
127
|
+
- - "~>"
|
|
128
|
+
- !ruby/object:Gem::Version
|
|
129
|
+
version: '0.9'
|
|
130
|
+
- !ruby/object:Gem::Dependency
|
|
131
|
+
name: webmock
|
|
132
|
+
requirement: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - "~>"
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '3.0'
|
|
137
|
+
type: :development
|
|
138
|
+
prerelease: false
|
|
139
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
140
|
+
requirements:
|
|
141
|
+
- - "~>"
|
|
142
|
+
- !ruby/object:Gem::Version
|
|
143
|
+
version: '3.0'
|
|
144
|
+
description: |
|
|
145
|
+
Rack middleware that gates a Sidekiq (or any Rack) dashboard behind
|
|
146
|
+
Cloudflare Access: it validates the Cf-Access-Jwt-Assertion JWT against your
|
|
147
|
+
team's JWKS, issuer, and audience (fail-closed) and audit-logs every request
|
|
148
|
+
that gets through. No Rails or logging-library dependency at runtime.
|
|
149
|
+
email:
|
|
150
|
+
- engineering@homebot.ai
|
|
151
|
+
executables: []
|
|
152
|
+
extensions: []
|
|
153
|
+
extra_rdoc_files: []
|
|
154
|
+
files:
|
|
155
|
+
- CHANGELOG.md
|
|
156
|
+
- LICENSE.txt
|
|
157
|
+
- README.md
|
|
158
|
+
- SECURITY.md
|
|
159
|
+
- lib/cloudflare_access_gate.rb
|
|
160
|
+
- lib/cloudflare_access_gate/audit_logger.rb
|
|
161
|
+
- lib/cloudflare_access_gate/gate.rb
|
|
162
|
+
- lib/cloudflare_access_gate/jwks_cache.rb
|
|
163
|
+
- lib/cloudflare_access_gate/structured_logger.rb
|
|
164
|
+
- lib/cloudflare_access_gate/version.rb
|
|
165
|
+
homepage: https://github.com/homebotapp/cloudflare_access_gate
|
|
166
|
+
licenses:
|
|
167
|
+
- MIT
|
|
168
|
+
metadata:
|
|
169
|
+
homepage_uri: https://github.com/homebotapp/cloudflare_access_gate
|
|
170
|
+
changelog_uri: https://github.com/homebotapp/cloudflare_access_gate/blob/main/CHANGELOG.md
|
|
171
|
+
bug_tracker_uri: https://github.com/homebotapp/cloudflare_access_gate/issues
|
|
172
|
+
rubygems_mfa_required: 'true'
|
|
173
|
+
rdoc_options: []
|
|
174
|
+
require_paths:
|
|
175
|
+
- lib
|
|
176
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
177
|
+
requirements:
|
|
178
|
+
- - ">="
|
|
179
|
+
- !ruby/object:Gem::Version
|
|
180
|
+
version: '3.1'
|
|
181
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
182
|
+
requirements:
|
|
183
|
+
- - ">="
|
|
184
|
+
- !ruby/object:Gem::Version
|
|
185
|
+
version: '0'
|
|
186
|
+
requirements: []
|
|
187
|
+
rubygems_version: 3.6.9
|
|
188
|
+
specification_version: 4
|
|
189
|
+
summary: Cloudflare Access JWT gate + audit logger Rack middleware.
|
|
190
|
+
test_files: []
|