diff --git a/CHANGELOG.md b/CHANGELOG.md index 7daa766..2afa0f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `content_dir//` 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 diff --git a/lib/volumen/admin_routes.rb b/lib/volumen/admin_routes.rb index 4c743af..f849da5 100644 --- a/lib/volumen/admin_routes.rb +++ b/lib/volumen/admin_routes.rb @@ -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 diff --git a/lib/volumen/api_routes.rb b/lib/volumen/api_routes.rb index d92005d..af412a2 100644 --- a/lib/volumen/api_routes.rb +++ b/lib/volumen/api_routes.rb @@ -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 diff --git a/lib/volumen/server.rb b/lib/volumen/server.rb index 7aaf77e..89bde0e 100644 --- a/lib/volumen/server.rb +++ b/lib/volumen/server.rb @@ -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! diff --git a/lib/volumen/store.rb b/lib/volumen/store.rb index 3b37092..17edfa7 100644 --- a/lib/volumen/store.rb +++ b/lib/volumen/store.rb @@ -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 diff --git a/lib/volumen/users.rb b/lib/volumen/users.rb index 78ee108..d7045a9 100644 --- a/lib/volumen/users.rb +++ b/lib/volumen/users.rb @@ -20,13 +20,22 @@ 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? - - [User.new("admin", @bootstrap_hash, "admin")] + 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? diff --git a/test/admin_test.rb b/test/admin_test.rb index 76e029c..7f1531a 100644 --- a/test/admin_test.rb +++ b/test/admin_test.rb @@ -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) diff --git a/test/server_test.rb b/test/server_test.rb index ccfe971..0e45e5e 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -119,4 +119,11 @@ class ServerTest < Minitest::Test assert_includes body, "https://example.com/hello" 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