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:
@@ -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
|
||||
|
||||
+13
-4
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user