otto 1.1.0.pre.alpha4 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/.rspec +4 -0
- data/.rubocop.yml +371 -25
- data/Gemfile +16 -5
- data/Gemfile.lock +115 -39
- data/LICENSE.txt +1 -1
- data/README.md +61 -112
- data/examples/basic/app.rb +78 -0
- data/examples/basic/config.ru +30 -0
- data/examples/basic/routes +20 -0
- data/examples/dynamic_pages/app.rb +115 -0
- data/examples/dynamic_pages/config.ru +30 -0
- data/{example → examples/dynamic_pages}/routes +5 -3
- data/examples/security_features/app.rb +276 -0
- data/examples/security_features/config.ru +83 -0
- data/examples/security_features/routes +11 -0
- data/lib/otto/design_system.rb +463 -0
- data/lib/otto/helpers/request.rb +124 -15
- data/lib/otto/helpers/response.rb +72 -14
- data/lib/otto/route.rb +111 -19
- data/lib/otto/security/config.rb +312 -0
- data/lib/otto/security/csrf.rb +177 -0
- data/lib/otto/security/validator.rb +299 -0
- data/lib/otto/static.rb +18 -5
- data/lib/otto/version.rb +2 -24
- data/lib/otto.rb +378 -127
- data/otto.gemspec +15 -15
- metadata +30 -29
- data/CHANGES.txt +0 -35
- data/VERSION.yml +0 -4
- data/example/app.rb +0 -58
- data/example/config.ru +0 -35
- /data/{example/public → public}/favicon.ico +0 -0
- /data/{example/public → public}/img/otto.jpg +0 -0
@@ -0,0 +1,276 @@
|
|
1
|
+
# examples/security_features/app.rb (Updated with Design System)
|
2
|
+
|
3
|
+
require_relative '../../lib/otto/design_system'
|
4
|
+
|
5
|
+
class SecureApp
|
6
|
+
include Otto::DesignSystem
|
7
|
+
include Otto::Security::CSRFHelpers
|
8
|
+
|
9
|
+
attr_reader :req, :res
|
10
|
+
|
11
|
+
def initialize(req, res)
|
12
|
+
@req = req
|
13
|
+
@res = res
|
14
|
+
res.headers['content-type'] = 'text/html; charset=utf-8'
|
15
|
+
end
|
16
|
+
|
17
|
+
def otto
|
18
|
+
self.class.otto
|
19
|
+
end
|
20
|
+
|
21
|
+
def index
|
22
|
+
csrf_tag = respond_to?(:csrf_form_tag) ? csrf_form_tag : ''
|
23
|
+
|
24
|
+
content = <<~HTML
|
25
|
+
<div class="otto-card otto-text-center">
|
26
|
+
<img src="/img/otto.jpg" alt="Otto Framework" class="otto-logo" />
|
27
|
+
<h1>Otto Security Features</h1>
|
28
|
+
<p class="otto-mb-md">Security demonstration for the Otto framework</p>
|
29
|
+
</div>
|
30
|
+
|
31
|
+
#{otto_card('CSRF Protected Feedback') do
|
32
|
+
<<~FORM
|
33
|
+
<form method="post" action="/feedback" class="otto-form">
|
34
|
+
#{csrf_tag}
|
35
|
+
<label>Message:</label>
|
36
|
+
#{otto_textarea('message', placeholder: 'Enter your feedback...', required: true)}
|
37
|
+
#{otto_button('Submit Feedback', variant: 'primary')}
|
38
|
+
</form>
|
39
|
+
FORM
|
40
|
+
end}
|
41
|
+
|
42
|
+
#{otto_card('File Upload Validation') do
|
43
|
+
<<~UPLOAD
|
44
|
+
<form method="post" action="/upload" enctype="multipart/form-data" class="otto-form">
|
45
|
+
#{csrf_tag}
|
46
|
+
<label>Choose file:</label>
|
47
|
+
<input type="file" name="upload_file" class="otto-input">
|
48
|
+
#{otto_button('Upload File', variant: 'primary')}
|
49
|
+
</form>
|
50
|
+
UPLOAD
|
51
|
+
end}
|
52
|
+
|
53
|
+
#{otto_card('User Profile Input Validation') do
|
54
|
+
<<~PROFILE
|
55
|
+
<form method="post" action="/profile" class="otto-form">
|
56
|
+
#{csrf_tag}
|
57
|
+
<label>Name:</label>
|
58
|
+
#{otto_input('name', placeholder: 'Your name', required: true)}
|
59
|
+
|
60
|
+
<label>Email:</label>
|
61
|
+
#{otto_input('email', type: 'email', placeholder: 'your@email.com', required: true)}
|
62
|
+
|
63
|
+
<label>Bio:</label>
|
64
|
+
#{otto_textarea('bio', placeholder: 'Tell us about yourself...')}
|
65
|
+
|
66
|
+
#{otto_button('Update Profile', variant: 'primary')}
|
67
|
+
</form>
|
68
|
+
PROFILE
|
69
|
+
end}
|
70
|
+
|
71
|
+
#{otto_card('Security Information') do
|
72
|
+
<<~INFO
|
73
|
+
<h3>Security Features Active:</h3>
|
74
|
+
<ul>
|
75
|
+
<li><strong>CSRF Protection:</strong> All forms include CSRF tokens</li>
|
76
|
+
<li><strong>Input Validation:</strong> Server-side validation with length limits</li>
|
77
|
+
<li><strong>XSS Prevention:</strong> HTML escaping for all user inputs</li>
|
78
|
+
<li><strong>Security Headers:</strong> Comprehensive header security policy</li>
|
79
|
+
</ul>
|
80
|
+
|
81
|
+
<p class="otto-mt-md">
|
82
|
+
<strong>Test XSS Protection:</strong> Try entering
|
83
|
+
#{otto_code_block('<script>alert("XSS")</script>', 'html')}
|
84
|
+
in any form field to see input sanitization in action.
|
85
|
+
</p>
|
86
|
+
|
87
|
+
<p class="otto-mt-md">
|
88
|
+
#{otto_link('View Request Headers', '/headers')}
|
89
|
+
</p>
|
90
|
+
INFO
|
91
|
+
end}
|
92
|
+
HTML
|
93
|
+
|
94
|
+
res.body = otto_page(content, 'Otto Security Features')
|
95
|
+
end
|
96
|
+
|
97
|
+
def receive_feedback
|
98
|
+
begin
|
99
|
+
message = req.params['message']
|
100
|
+
|
101
|
+
if respond_to?(:validate_input)
|
102
|
+
safe_message = validate_input(message, max_length: 1000, allow_html: false)
|
103
|
+
else
|
104
|
+
safe_message = message.to_s.strip
|
105
|
+
raise 'Message too long' if safe_message.length > 1000
|
106
|
+
end
|
107
|
+
|
108
|
+
content = if safe_message.empty?
|
109
|
+
otto_alert('error', 'Validation Error', 'Message cannot be empty.')
|
110
|
+
else
|
111
|
+
<<~HTML
|
112
|
+
#{otto_alert('success', 'Feedback Received', 'Thank you for your feedback!')}
|
113
|
+
|
114
|
+
#{otto_card('Your Message') do
|
115
|
+
otto_code_block(safe_message, 'text')
|
116
|
+
end}
|
117
|
+
HTML
|
118
|
+
end
|
119
|
+
rescue Otto::Security::ValidationError => ex
|
120
|
+
content = otto_alert('error', 'Security Validation Failed', ex.message)
|
121
|
+
rescue StandardError
|
122
|
+
content = otto_alert('error', 'Processing Error', 'An error occurred processing your request.')
|
123
|
+
end
|
124
|
+
|
125
|
+
content += "<p class=\"otto-mt-lg\">#{otto_link('← Back to form', '/')}</p>"
|
126
|
+
res.body = otto_page(content, 'Feedback Response')
|
127
|
+
end
|
128
|
+
|
129
|
+
def upload_file
|
130
|
+
begin
|
131
|
+
uploaded_file = req.params['upload_file']
|
132
|
+
|
133
|
+
if uploaded_file.nil? || uploaded_file.empty?
|
134
|
+
content = otto_alert('error', 'Upload Error', 'No file was selected.')
|
135
|
+
else
|
136
|
+
begin
|
137
|
+
filename = begin
|
138
|
+
uploaded_file[:filename]
|
139
|
+
rescue StandardError
|
140
|
+
uploaded_file.original_filename
|
141
|
+
end
|
142
|
+
rescue StandardError
|
143
|
+
'unknown'
|
144
|
+
end
|
145
|
+
|
146
|
+
safe_filename = if respond_to?(:sanitize_filename)
|
147
|
+
sanitize_filename(filename)
|
148
|
+
else
|
149
|
+
File.basename(filename.to_s).gsub(/[^\w\-_\.]/, '_')
|
150
|
+
end
|
151
|
+
|
152
|
+
file_info = {
|
153
|
+
'Original filename' => filename,
|
154
|
+
'Sanitized filename' => safe_filename,
|
155
|
+
'Content type' => uploaded_file[:type] || 'unknown',
|
156
|
+
'Security status' => 'File validated and processed safely',
|
157
|
+
}
|
158
|
+
|
159
|
+
info_html = file_info.map do |key, value|
|
160
|
+
"<p><strong>#{key}:</strong> #{escape_html(value)}</p>"
|
161
|
+
end.join
|
162
|
+
|
163
|
+
content = <<~HTML
|
164
|
+
#{otto_alert('success', 'File Upload Successful', 'File processed and validated successfully!')}
|
165
|
+
|
166
|
+
#{otto_card('File Information') do
|
167
|
+
info_html
|
168
|
+
end}
|
169
|
+
|
170
|
+
<div class="otto-alert otto-alert-info">
|
171
|
+
<h3 class="otto-alert-title">Demo Notice</h3>
|
172
|
+
<p class="otto-alert-message">This is a demonstration - files are validated but not permanently stored.</p>
|
173
|
+
</div>
|
174
|
+
HTML
|
175
|
+
end
|
176
|
+
rescue Otto::Security::ValidationError => ex
|
177
|
+
content = otto_alert('error', 'File Validation Failed', ex.message)
|
178
|
+
rescue StandardError
|
179
|
+
content = otto_alert('error', 'Upload Error', 'An error occurred during file upload.')
|
180
|
+
end
|
181
|
+
|
182
|
+
content += "<p class=\"otto-mt-lg\">#{otto_link('← Back to form', '/')}</p>"
|
183
|
+
res.body = otto_page(content, 'Upload Response')
|
184
|
+
end
|
185
|
+
|
186
|
+
def update_profile
|
187
|
+
begin
|
188
|
+
name = req.params['name']
|
189
|
+
email = req.params['email']
|
190
|
+
bio = req.params['bio']
|
191
|
+
|
192
|
+
if respond_to?(:validate_input)
|
193
|
+
safe_name = validate_input(name, max_length: 100)
|
194
|
+
safe_email = validate_input(email, max_length: 255)
|
195
|
+
safe_bio = validate_input(bio, max_length: 500, allow_html: false)
|
196
|
+
else
|
197
|
+
safe_name = name.to_s.strip[0..99]
|
198
|
+
safe_email = email.to_s.strip[0..254]
|
199
|
+
safe_bio = bio.to_s.strip[0..499]
|
200
|
+
end
|
201
|
+
|
202
|
+
unless safe_email.match?(/\A[^@\s]+@[^@\s]+\z/)
|
203
|
+
raise Otto::Security::ValidationError, 'Invalid email format'
|
204
|
+
end
|
205
|
+
|
206
|
+
profile_data = {
|
207
|
+
'Name' => safe_name,
|
208
|
+
'Email' => safe_email,
|
209
|
+
'Bio' => safe_bio,
|
210
|
+
'Updated' => Time.now.strftime('%Y-%m-%d %H:%M:%S UTC'),
|
211
|
+
}
|
212
|
+
|
213
|
+
profile_html = profile_data.map do |key, value|
|
214
|
+
"<p><strong>#{key}:</strong> #{escape_html(value)}</p>"
|
215
|
+
end.join
|
216
|
+
|
217
|
+
content = <<~HTML
|
218
|
+
#{otto_alert('success', 'Profile Updated', 'Your profile has been updated successfully!')}
|
219
|
+
|
220
|
+
#{otto_card('Profile Data') do
|
221
|
+
profile_html
|
222
|
+
end}
|
223
|
+
HTML
|
224
|
+
rescue Otto::Security::ValidationError => ex
|
225
|
+
content = otto_alert('error', 'Profile Validation Failed', ex.message)
|
226
|
+
rescue StandardError
|
227
|
+
content = otto_alert('error', 'Update Error', 'An error occurred updating your profile.')
|
228
|
+
end
|
229
|
+
|
230
|
+
content += "<p class=\"otto-mt-lg\">#{otto_link('← Back to form', '/')}</p>"
|
231
|
+
res.body = otto_page(content, 'Profile Update')
|
232
|
+
end
|
233
|
+
|
234
|
+
def show_headers
|
235
|
+
res.headers['content-type'] = 'application/json; charset=utf-8'
|
236
|
+
|
237
|
+
safe_headers = {}
|
238
|
+
req.env.each do |key, value|
|
239
|
+
if key.start_with?('HTTP_') || %w[REQUEST_METHOD PATH_INFO QUERY_STRING].include?(key)
|
240
|
+
safe_headers[key] = value.to_s[0..200]
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
response_data = {
|
245
|
+
message: 'Request headers analysis (filtered for security)',
|
246
|
+
client_ip: req.respond_to?(:client_ipaddress) ? req.client_ipaddress : req.ip,
|
247
|
+
secure_connection: req.respond_to?(:secure?) ? req.secure? : false,
|
248
|
+
timestamp: Time.now.utc.iso8601,
|
249
|
+
headers: safe_headers,
|
250
|
+
security_analysis: {
|
251
|
+
csrf_protection: respond_to?(:csrf_token_valid?) ? 'Active' : 'Basic',
|
252
|
+
content_security: 'Headers validated and filtered',
|
253
|
+
xss_protection: 'HTML escaping enabled',
|
254
|
+
},
|
255
|
+
}
|
256
|
+
|
257
|
+
require 'json'
|
258
|
+
res.body = JSON.pretty_generate(response_data)
|
259
|
+
end
|
260
|
+
|
261
|
+
def not_found
|
262
|
+
res.status = 404
|
263
|
+
content = otto_alert('error', 'Page Not Found', 'The requested page could not be found.')
|
264
|
+
content += "<p>#{otto_link('← Back to home', '/')}</p>"
|
265
|
+
res.body = otto_page(content, '404 - Not Found')
|
266
|
+
end
|
267
|
+
|
268
|
+
def server_error
|
269
|
+
res.status = 500
|
270
|
+
error_id = req.env['otto.error_id'] || SecureRandom.hex(8)
|
271
|
+
content = otto_alert('error', 'Server Error', 'An internal server error occurred.')
|
272
|
+
content += "<p><small>Error ID: #{escape_html(error_id)}</small></p>"
|
273
|
+
content += "<p>#{otto_link('← Back to home', '/')}</p>"
|
274
|
+
res.body = otto_page(content, '500 - Server Error')
|
275
|
+
end
|
276
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# examples/security_features/config.ru
|
2
|
+
|
3
|
+
# OTTO SECURE EXAMPLE APP CONFIG - 2025-07-18
|
4
|
+
#
|
5
|
+
# Usage:
|
6
|
+
#
|
7
|
+
# $ thin -e dev -R config.ru -p 10770 start
|
8
|
+
#
|
9
|
+
|
10
|
+
public_path = File.expand_path('../../public', __dir__)
|
11
|
+
|
12
|
+
require_relative '../../lib/otto'
|
13
|
+
require_relative 'app'
|
14
|
+
|
15
|
+
# Create Otto app with security features enabled
|
16
|
+
app = Otto.new('./routes', {
|
17
|
+
# Enable CSRF protection for POST, PUT, DELETE requests
|
18
|
+
csrf_protection: true,
|
19
|
+
|
20
|
+
# Enable input validation and sanitization
|
21
|
+
request_validation: true,
|
22
|
+
|
23
|
+
# Configure trusted proxy servers (adjust for your infrastructure)
|
24
|
+
trusted_proxies: [
|
25
|
+
# The primary RFC 1918 private ranges
|
26
|
+
'127.0.0.1', # Local development
|
27
|
+
'10.0.0.0/8', # Private Class A
|
28
|
+
'172.16.0.0/12', # Private Class B
|
29
|
+
'192.168.0.0/16', # Private Class C
|
30
|
+
|
31
|
+
# Other reserved ranges that I often forget about
|
32
|
+
# '127.0.0.0/8', # Loopback
|
33
|
+
# '100.64.0.0/10', # Carrier-grade NAT
|
34
|
+
# '169.254.0.0/16', # RFC 3927 - Automatic Private IP Addressing (APIPA)
|
35
|
+
# '198.18.0.0/15', # RFC 2544 - Benchmarking methodology
|
36
|
+
# '203.0.113.0/24', # RFC 5737 - Documentation examples
|
37
|
+
# '224.0.0.0/4', # Multicast
|
38
|
+
# '240.0.0.0/4', # RFC 1112 - Class E (experimental)
|
39
|
+
],
|
40
|
+
|
41
|
+
# Custom security headers
|
42
|
+
security_headers: {
|
43
|
+
'content-security-policy' => "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'",
|
44
|
+
'strict-transport-security' => 'max-age=31536000; includeSubDomains',
|
45
|
+
'x-frame-options' => 'DENY',
|
46
|
+
},
|
47
|
+
}
|
48
|
+
)
|
49
|
+
|
50
|
+
# Optional: Configure additional security settings
|
51
|
+
app.security_config.max_request_size = 5 * 1024 * 1024 # 5MB limit
|
52
|
+
app.security_config.max_param_depth = 10 # Limit parameter nesting
|
53
|
+
app.security_config.max_param_keys = 50 # Limit parameters per request
|
54
|
+
|
55
|
+
# Optional: Add static file serving with security
|
56
|
+
app.option[:public] = public_path
|
57
|
+
|
58
|
+
# Development vs Production configuration
|
59
|
+
if ENV['RACK_ENV'] == 'production'
|
60
|
+
# Production-specific settings
|
61
|
+
app.security_config.require_secure_cookies = true
|
62
|
+
|
63
|
+
# More restrictive CSP for production
|
64
|
+
app.set_security_headers({
|
65
|
+
'content-security-policy' => "default-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'",
|
66
|
+
'strict-transport-security' => 'max-age=63072000; includeSubDomains; preload',
|
67
|
+
},
|
68
|
+
)
|
69
|
+
else
|
70
|
+
# Development-specific settings
|
71
|
+
puts '🔒 Security features enabled:'
|
72
|
+
puts ' ✓ CSRF Protection'
|
73
|
+
puts ' ✓ Input Validation'
|
74
|
+
puts ' ✓ Request Size Limits'
|
75
|
+
puts ' ✓ Security Headers'
|
76
|
+
puts ' ✓ Trusted Proxy Support'
|
77
|
+
puts ''
|
78
|
+
end
|
79
|
+
|
80
|
+
# Mount the application
|
81
|
+
map('/') do
|
82
|
+
run app
|
83
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# examples/security_features/routes
|
2
|
+
|
3
|
+
GET / SecureApp#index
|
4
|
+
POST /feedback SecureApp#receive_feedback
|
5
|
+
POST /upload SecureApp#upload_file
|
6
|
+
POST /profile SecureApp#update_profile
|
7
|
+
GET /headers SecureApp#show_headers
|
8
|
+
|
9
|
+
# Error handlers (optional)
|
10
|
+
GET /404 SecureApp#not_found
|
11
|
+
GET /500 SecureApp#server_error
|