security: CORS preflight, session invalidation, CSRF on preview, symlink containment, and hardening

- Add OPTIONS /api/volumen/* for CORS preflight (S-22).
- Invalidate session in require_login! when user no longer exists (S-8).
- Add check_csrf! to POST /admin/preview (S-1).
- Use File.realpath for symlink-safe containment in media_file (S-2).
- Memoize Users#all with mtime-based invalidation (S-12).
- Document that empty permitted_hosts means allow-all (S-5).
- Warn and fall back to random when session_key is too short (S-20).
This commit is contained in:
2026-06-25 22:06:49 +02:00
parent 5b8236c0a4
commit 52be2bb996
8 changed files with 76 additions and 9 deletions
+7
View File
@@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`content_dir/<lang>/` instead of overwriting root-level files. `content_dir/<lang>/` instead of overwriting root-level files.
- CLI test coverage (version, help, unknown command, option parsing, config bootstrap). - CLI test coverage (version, help, unknown command, option parsing, config bootstrap).
- Test coverage for RSS feed and sitemap endpoints. - Test coverage for RSS feed and sitemap endpoints.
- CORS preflight (`OPTIONS /api/volumen/*`) so browsers can reach the JSON API.
- `Users#all` memoization with mtime-based invalidation, matching Store#all.
- Session invalidation: deleted users can no longer use existing session cookies.
- CSRF protection on `POST /admin/preview`.
- Symlink-safe containment in `Store#media_file` via `File.realpath`.
### Changed ### Changed
@@ -39,6 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Draft posts no longer leak through the public detail endpoint (`/api/volumen/posts/:slug`). - Draft posts no longer leak through the public detail endpoint (`/api/volumen/posts/:slug`).
- `Config::DEFAULTS` inner hashes are now frozen and `deep_merge` builds fresh - `Config::DEFAULTS` inner hashes are now frozen and `deep_merge` builds fresh
copies, preventing mutation from silently poisoning later `Config.load` calls. copies, preventing mutation from silently poisoning later `Config.load` calls.
- A warning is emitted when `admin.session_key` is configured but shorter than
64 bytes, instead of silently falling back to a random secret.
## [0.1.0] — 2026-06-20 ## [0.1.0] — 2026-06-20
+1
View File
@@ -76,6 +76,7 @@ module Volumen
app.post "/admin/preview" do app.post "/admin/preview" do
require_login! require_login!
check_csrf!
content_type :html content_type :html
Markdown.render(params["body"].to_s) Markdown.render(params["body"].to_s)
end end
+8
View File
@@ -9,6 +9,14 @@ module Volumen
content_type :json content_type :json
end end
app.options "/api/volumen/*" do
headers "Access-Control-Allow-Origin" => "*"
headers "Access-Control-Allow-Methods" => "GET, OPTIONS"
headers "Access-Control-Allow-Headers" => "Content-Type"
content_type :json
""
end
app.get "/api/volumen/site" do app.get "/api/volumen/site" do
json(site_payload) json(site_payload)
end end
+18 -3
View File
@@ -25,7 +25,7 @@ module Volumen
configure do configure do
set :show_exceptions, false set :show_exceptions, false
set :raise_errors, false set :raise_errors, false
set :host_authorization, { permitted_hosts: [] } set :host_authorization, { permitted_hosts: [] } # empty = allow all hosts
set :views, File.expand_path("web/views", __dir__) set :views, File.expand_path("web/views", __dir__)
end end
@@ -44,7 +44,14 @@ module Volumen
def self.session_secret(config) def self.session_secret(config)
key = config.admin["session_key"].to_s key = config.admin["session_key"].to_s
key.bytesize >= 64 ? key : SecureRandom.hex(64) return SecureRandom.hex(64) if key.empty?
if key.bytesize < 64
warn "volumen: session_key is shorter than 64 bytes, falling back to random secret"
return SecureRandom.hex(64)
end
key
end end
# Mark the session cookie Secure only when the request is HTTPS (directly or # Mark the session cookie Secure only when the request is HTTPS (directly or
@@ -207,7 +214,15 @@ module Volumen
end end
def require_login! def require_login!
redirect to("/admin/login") unless authenticated? unless authenticated?
redirect to("/admin/login")
return
end
return unless current_user_record.nil?
session.clear
redirect to("/admin/login")
end end
def require_admin! def require_admin!
+3 -2
View File
@@ -67,9 +67,10 @@ module Volumen
def media_file(name) def media_file(name)
path = File.join(media_dir, File.basename(name.to_s)) path = File.join(media_dir, File.basename(name.to_s))
return nil unless File.file?(path) && within?(media_dir, path) return nil unless File.file?(path)
path real = File.realpath(path)
within?(media_dir, real) ? path : nil
end end
private private
+13 -4
View File
@@ -20,13 +20,22 @@ module Volumen
def initialize(path:, bootstrap_hash: nil) def initialize(path:, bootstrap_hash: nil)
@path = path @path = path
@bootstrap_hash = bootstrap_hash.to_s @bootstrap_hash = bootstrap_hash.to_s
@all = nil
end end
def all def all
return read_file if File.exist?(@path) mtime = File.exist?(@path) ? File.mtime(@path) : nil
return [] if @bootstrap_hash.empty? @all = nil if mtime != @all_mtime
@all ||= begin
[User.new("admin", @bootstrap_hash, "admin")] @all_mtime = mtime
if File.exist?(@path)
read_file
elsif @bootstrap_hash.empty?
[]
else
[User.new("admin", @bootstrap_hash, "admin")]
end
end
end end
def any? def any?
+19
View File
@@ -263,6 +263,25 @@ class AdminTest < Minitest::Test
assert_includes last_response.body, "Username unchanged" assert_includes last_response.body, "Username unchanged"
end end
def test_preview_requires_csrf
login
get "/admin/posts/new"
post "/admin/preview", "body" => "**hi**"
assert_equal 403, last_response.status
end
def test_orphaned_session_is_rejected
login
session_for = -> { rack_mock_session.cookie_jar["rack.session"].to_s }
refute_empty session_for.call, "expected a session cookie after login"
# Simulate a deleted user by clearing users.toml while keeping the cookie.
File.write(@users_path, "")
get "/admin/"
assert_equal 302, last_response.status
assert_equal "http://example.org/admin/login", last_response.location
end
private private
def csrf(body) def csrf(body)
+7
View File
@@ -119,4 +119,11 @@ class ServerTest < Minitest::Test
assert_includes body, "<loc>https://example.com/hello</loc>" assert_includes body, "<loc>https://example.com/hello</loc>"
refute_includes body, "draft" refute_includes body, "draft"
end end
def test_cors_preflight
options "/api/volumen/site"
assert_predicate last_response, :ok?
assert_equal "*", last_response.headers["Access-Control-Allow-Origin"]
assert_equal "GET, OPTIONS", last_response.headers["Access-Control-Allow-Methods"]
end
end end