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
+1
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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!
+3 -2
View File
@@ -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
+13 -4
View File
@@ -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?