security: add slug validation, username validation, and template escaping

- Enforce SLUG_REGEX (alphanumeric + dot/dash/underscore only) in creation_error
  to prevent path traversal (C-1) and stored XSS (C-3).
- Add File.expand_path containment check in default_path_for as defense-in-depth.
- Escape slug in form action attributes (list, form views).
- Validate username in change_username using the same regex as create_user (C-4).
- Escape username in settings form action attributes.
- Treat submitting the current username as a no-op success instead of 'taken'.
This commit is contained in:
2026-06-25 22:06:49 +02:00
parent d26d3b5a5b
commit 1ca0212197
7 changed files with 58 additions and 6 deletions
+34
View File
@@ -229,6 +229,40 @@ class AdminTest < Minitest::Test
"sweep should remove IPs with only stale entries"
end
def test_create_post_rejects_invalid_slug
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "../../etc/passwd",
"lang" => "en", "body" => "y"
assert_includes last_response.body, "Invalid slug"
end
def test_create_post_rejects_xss_in_slug
login
get "/admin/posts/new"
post "/admin/posts",
"_csrf" => csrf(last_response.body), "title" => "X", "slug" => "foo\" onclick",
"lang" => "en", "body" => "y"
assert_includes last_response.body, "Invalid slug"
end
def test_change_username_rejects_special_chars
login
get "/admin/settings"
post "/admin/settings/username",
"_csrf" => csrf(last_response.body), "username" => "foo\" bar"
assert_includes last_response.body, "Username may use letters"
end
def test_change_username_accepts_same_name
login
get "/admin/settings"
post "/admin/settings/username",
"_csrf" => csrf(last_response.body), "username" => "admin"
assert_includes last_response.body, "Username unchanged"
end
private
def csrf(body)
+6
View File
@@ -49,4 +49,10 @@ class StoreTest < Minitest::Test
assert store.find("global", lang: "cs")
assert store.find("global", lang: "en")
end
def test_save_rejects_path_traversal_slug
store = Volumen::Store.new(@dir)
post = Volumen::Post.new(metadata: { "slug" => "../../etc/passwd" }, body: "x")
assert_raises(ArgumentError, "slug escapes content directory") { store.save(post) }
end
end