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.
|
`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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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!
|
||||||
|
|||||||
@@ -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
@@ -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?
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user