Skip to content

Commit 38167b4

Browse files
newstlerclaude
andauthored
fix(security): block file extension probes and dotfile access in middleware (#140)
Enhance MaliciousPathBlocker to catch scanner probes that request paths with file extensions (e.g. /delete.sql, /secrets.txt) when no matching file exists in public/. Also block dotfile requests (.rbenv-vars, .yarnrc, etc.) and tighten catch-all route constraints to exclude paths containing dots so static file requests fall through properly. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d508821 commit 38167b4

4 files changed

Lines changed: 72 additions & 9 deletions

File tree

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ GEM
137137
bindex (0.8.1)
138138
bootsnap (1.21.1)
139139
msgpack (~> 1.2)
140-
brakeman (8.0.2)
140+
brakeman (8.0.4)
141141
racc
142142
builder (3.3.0)
143143
capybara (3.40.0)
@@ -642,7 +642,7 @@ CHECKSUMS
642642
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
643643
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
644644
bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4
645-
brakeman (8.0.2) sha256=7b02065ce8b1de93949cefd3f2ad78e8eb370e644b95c8556a32a912a782426a
645+
brakeman (8.0.4) sha256=7bf921fa9638544835df9aa7b3e720a9a72c0267f34f92135955edd80d4dcf6f
646646
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
647647
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
648648
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab

config/routes.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# Redirect /community paths that may linger from old links or crawlers
1616
get "community", to: redirect("/", status: 301)
1717
get "community/:id", to: redirect("/%{id}", status: 301), constraints: { id: /[^\/]+/ }
18-
get ":id", to: "users#show", as: :rubycommunity_user, constraints: { id: /[^\/]+/ }
18+
get ":id", to: "users#show", as: :rubycommunity_user, constraints: { id: /[^\/\.]+/ }
1919
end
2020

2121
# Add sign out route for OmniAuth-only authentication
@@ -104,10 +104,11 @@
104104
get "legal", to: "legal#show", defaults: { page: "legal_notice" }, as: :legal_notice
105105

106106
# Category routes (must be at the end due to catch-all nature)
107-
get ":id", to: "categories#show", as: :category, constraints: { id: /[^\/]+/ }
107+
# Exclude paths with dots so file requests (sitemap.xml, robots.txt) fall through to public/
108+
get ":id", to: "categories#show", as: :category, constraints: { id: /[^\/\.]+/ }
108109

109110
# Post routes (must be after category)
110-
get ":category_id/:id", to: "posts#show", as: :post, constraints: { category_id: /[^\/]+/, id: /[^\/]+/ }
111+
get ":category_id/:id", to: "posts#show", as: :post, constraints: { category_id: /[^\/\.]+/, id: /[^\/\.]+/ }
111112
get ":category_id/:id/og-image.webp", to: "posts#image", as: :post_image, constraints: { category_id: /[^\/]+/, id: /[^\/]+/ }
112113

113114
# Defines the root path route ("/")

lib/middleware/malicious_path_blocker.rb

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
# frozen_string_literal: true
22

33
module Middleware
4-
# Blocks known malicious request paths (WordPress exploits, PHP files, etc.)
5-
# before they reach the Rails router. Runs early in the middleware stack
6-
# for efficiency.
4+
# Blocks known malicious request paths before they reach the Rails router.
5+
# Two strategies:
6+
# 1. Blocklist: known exploit patterns (WordPress, PHP, etc.)
7+
# 2. File extension guard: any path with a file extension that doesn't
8+
# correspond to an actual file in public/ gets blocked. This prevents
9+
# scanners from probing paths like /delete.sql, /secrets.txt, etc.
710
class MaliciousPathBlocker
811
BLOCKED_PATTERNS = [
912
# WordPress
@@ -16,11 +19,17 @@ class MaliciousPathBlocker
1619
/phpinfo/i, /phpmyadmin/i,
1720
/admin\.php/i, /setup\.php/i,
1821
# Path traversal
19-
/\.\.\//
22+
/\.\.\//,
23+
# Dotfiles (e.g. .rbenv-vars, .yarnrc, .dockerignore)
24+
/\/\.[^\/]+$/
2025
].freeze
2126

27+
# Matches paths like /foo.xml, /bar.txt, /Dockerfile (no extension but known probe)
28+
FILE_EXTENSION_PATTERN = /\.[a-zA-Z0-9]+$/
29+
2230
def initialize(app)
2331
@app = app
32+
@public_path = Rails.public_path
2433
end
2534

2635
def call(env)
@@ -29,6 +38,9 @@ def call(env)
2938
if blocked_path?(path)
3039
log_blocked_request(env, path)
3140
[ 403, { "Content-Type" => "text/plain" }, [ "Forbidden" ] ]
41+
elsif unknown_file_request?(path)
42+
log_blocked_request(env, path)
43+
[ 404, { "Content-Type" => "text/plain" }, [ "Not Found" ] ]
3244
else
3345
@app.call(env)
3446
end
@@ -40,6 +52,13 @@ def blocked_path?(path)
4052
BLOCKED_PATTERNS.any? { |pattern| path.match?(pattern) }
4153
end
4254

55+
def unknown_file_request?(path)
56+
return false unless path.match?(FILE_EXTENSION_PATTERN)
57+
58+
# Allow if a real file exists in public/
59+
!File.exist?(File.join(@public_path, path))
60+
end
61+
4362
def log_blocked_request(env, path)
4463
ip = env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip || env["REMOTE_ADDR"]
4564
Rails.logger.warn "[MaliciousPathBlocker] Blocked: #{path} from #{ip}"

test/middleware/malicious_path_blocker_test.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,55 @@ class MaliciousPathBlockerTest < ActionDispatch::IntegrationTest
4747
assert_response :forbidden
4848
end
4949

50+
# Dotfiles (scanner probes)
51+
test "blocks dotfile requests like .rbenv-vars" do
52+
get "/.rbenv-vars"
53+
assert_response :forbidden
54+
end
55+
56+
test "blocks dotfile requests like .yarnrc" do
57+
get "/.yarnrc"
58+
assert_response :forbidden
59+
end
60+
61+
# File extension probes (not in public/)
62+
test "blocks unknown file extensions like delete.sql" do
63+
get "/delete.sql"
64+
assert_response :not_found
65+
end
66+
67+
test "blocks unknown file extensions like secrets.txt" do
68+
get "/secrets.txt"
69+
assert_response :not_found
70+
end
71+
72+
test "blocks unknown file extensions like remove.sh" do
73+
get "/remove.sh"
74+
assert_response :not_found
75+
end
76+
77+
test "blocks unknown file extensions like sitemap.xml" do
78+
get "/sitemap.xml"
79+
assert_response :not_found
80+
end
81+
5082
# Apple touch icon — legitimate iOS request, served as static file
5183
test "allows apple-touch-icon-precomposed requests" do
5284
get "/apple-touch-icon-precomposed.png"
5385
assert_response :success
5486
end
5587

88+
# Known public files should pass through
89+
test "allows robots.txt" do
90+
get "/robots.txt"
91+
assert_response :success
92+
end
93+
94+
test "allows favicon.ico" do
95+
get "/favicon.ico"
96+
assert_response :success
97+
end
98+
5699
# Case insensitivity
57100
test "blocks uppercase WordPress paths" do
58101
get "/WP-ADMIN"

0 commit comments

Comments
 (0)