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:
@@ -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.
|
||||
- CLI test coverage (version, help, unknown command, option parsing, config bootstrap).
|
||||
- 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
|
||||
|
||||
@@ -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`).
|
||||
- `Config::DEFAULTS` inner hashes are now frozen and `deep_merge` builds fresh
|
||||
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
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ module Volumen
|
||||
|
||||
app.post "/admin/preview" do
|
||||
require_login!
|
||||
check_csrf!
|
||||
content_type :html
|
||||
Markdown.render(params["body"].to_s)
|
||||
end
|
||||
|
||||
@@ -9,6 +9,14 @@ module Volumen
|
||||
content_type :json
|
||||
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
|
||||
json(site_payload)
|
||||
end
|
||||
|
||||
+18
-3
@@ -25,7 +25,7 @@ module Volumen
|
||||
configure do
|
||||
set :show_exceptions, 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__)
|
||||
end
|
||||
|
||||
@@ -44,7 +44,14 @@ module Volumen
|
||||
|
||||
def self.session_secret(config)
|
||||
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
|
||||
|
||||
# Mark the session cookie Secure only when the request is HTTPS (directly or
|
||||
@@ -207,7 +214,15 @@ module Volumen
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def require_admin!
|
||||
|
||||
@@ -67,9 +67,10 @@ module Volumen
|
||||
|
||||
def media_file(name)
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
+12
-3
@@ -20,14 +20,23 @@ module Volumen
|
||||
def initialize(path:, bootstrap_hash: nil)
|
||||
@path = path
|
||||
@bootstrap_hash = bootstrap_hash.to_s
|
||||
@all = nil
|
||||
end
|
||||
|
||||
def all
|
||||
return read_file if File.exist?(@path)
|
||||
return [] if @bootstrap_hash.empty?
|
||||
|
||||
mtime = File.exist?(@path) ? File.mtime(@path) : nil
|
||||
@all = nil if mtime != @all_mtime
|
||||
@all ||= begin
|
||||
@all_mtime = mtime
|
||||
if File.exist?(@path)
|
||||
read_file
|
||||
elsif @bootstrap_hash.empty?
|
||||
[]
|
||||
else
|
||||
[User.new("admin", @bootstrap_hash, "admin")]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def any?
|
||||
all.any?
|
||||
|
||||
@@ -263,6 +263,25 @@ class AdminTest < Minitest::Test
|
||||
assert_includes last_response.body, "Username unchanged"
|
||||
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
|
||||
|
||||
def csrf(body)
|
||||
|
||||
@@ -119,4 +119,11 @@ class ServerTest < Minitest::Test
|
||||
assert_includes body, "<loc>https://example.com/hello</loc>"
|
||||
refute_includes body, "draft"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user