Skip to content

Commit 76eb625

Browse files
authored
Merge pull request #690 from puppetlabs/P4DEVOPS-9434
Add rate limiting and input validation security enhancements
2 parents 50efc5b + d0020be commit 76eb625

File tree

5 files changed

+542
-15
lines changed

5 files changed

+542
-15
lines changed

lib/vmpooler/api/helpers.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# frozen_string_literal: true
22

3+
require 'vmpooler/api/input_validator'
4+
35
module Vmpooler
46

57
class API
68

79
module Helpers
10+
include InputValidator
811

912
def tracer
1013
@tracer ||= OpenTelemetry.tracer_provider.tracer('api', Vmpooler::VERSION)
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# frozen_string_literal: true
2+
3+
module Vmpooler
4+
class API
5+
# Input validation helpers to enhance security
6+
module InputValidator
7+
# Maximum lengths to prevent abuse
8+
MAX_HOSTNAME_LENGTH = 253
9+
MAX_TAG_KEY_LENGTH = 50
10+
MAX_TAG_VALUE_LENGTH = 255
11+
MAX_REASON_LENGTH = 500
12+
MAX_POOL_NAME_LENGTH = 100
13+
MAX_TOKEN_LENGTH = 64
14+
15+
# Valid patterns
16+
HOSTNAME_PATTERN = /\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)* \z/ix.freeze
17+
POOL_NAME_PATTERN = /\A[a-zA-Z0-9_-]+\z/.freeze
18+
TAG_KEY_PATTERN = /\A[a-zA-Z0-9_\-.]+\z/.freeze
19+
TOKEN_PATTERN = /\A[a-zA-Z0-9\-_]+\z/.freeze
20+
INTEGER_PATTERN = /\A\d+\z/.freeze
21+
22+
class ValidationError < StandardError; end
23+
24+
# Validate hostname format and length
25+
def validate_hostname(hostname)
26+
return error_response('Hostname is required') if hostname.nil? || hostname.empty?
27+
return error_response('Hostname too long') if hostname.length > MAX_HOSTNAME_LENGTH
28+
return error_response('Invalid hostname format') unless hostname.match?(HOSTNAME_PATTERN)
29+
30+
true
31+
end
32+
33+
# Validate pool/template name
34+
def validate_pool_name(pool_name)
35+
return error_response('Pool name is required') if pool_name.nil? || pool_name.empty?
36+
return error_response('Pool name too long') if pool_name.length > MAX_POOL_NAME_LENGTH
37+
return error_response('Invalid pool name format') unless pool_name.match?(POOL_NAME_PATTERN)
38+
39+
true
40+
end
41+
42+
# Validate tag key and value
43+
def validate_tag(key, value)
44+
return error_response('Tag key is required') if key.nil? || key.empty?
45+
return error_response('Tag key too long') if key.length > MAX_TAG_KEY_LENGTH
46+
return error_response('Invalid tag key format') unless key.match?(TAG_KEY_PATTERN)
47+
48+
if value
49+
return error_response('Tag value too long') if value.length > MAX_TAG_VALUE_LENGTH
50+
51+
# Sanitize value to prevent injection attacks
52+
sanitized_value = value.gsub(/[^\w\s\-.@:\/]/, '')
53+
return error_response('Tag value contains invalid characters') if sanitized_value != value
54+
end
55+
56+
true
57+
end
58+
59+
# Validate token format
60+
def validate_token_format(token)
61+
return error_response('Token is required') if token.nil? || token.empty?
62+
return error_response('Token too long') if token.length > MAX_TOKEN_LENGTH
63+
return error_response('Invalid token format') unless token.match?(TOKEN_PATTERN)
64+
65+
true
66+
end
67+
68+
# Validate integer parameter
69+
def validate_integer(value, name = 'value', min: nil, max: nil)
70+
return error_response("#{name} is required") if value.nil?
71+
72+
value_str = value.to_s
73+
return error_response("#{name} must be a valid integer") unless value_str.match?(INTEGER_PATTERN)
74+
75+
int_value = value.to_i
76+
return error_response("#{name} must be at least #{min}") if min && int_value < min
77+
return error_response("#{name} must be at most #{max}") if max && int_value > max
78+
79+
int_value
80+
end
81+
82+
# Validate VM request count
83+
def validate_vm_count(count)
84+
validated = validate_integer(count, 'VM count', min: 1, max: 100)
85+
return validated if validated.is_a?(Hash) # error response
86+
87+
validated
88+
end
89+
90+
# Validate disk size
91+
def validate_disk_size(size)
92+
validated = validate_integer(size, 'Disk size', min: 1, max: 2048)
93+
return validated if validated.is_a?(Hash) # error response
94+
95+
validated
96+
end
97+
98+
# Validate lifetime (TTL) in hours
99+
def validate_lifetime(lifetime)
100+
validated = validate_integer(lifetime, 'Lifetime', min: 1, max: 168) # max 1 week
101+
return validated if validated.is_a?(Hash) # error response
102+
103+
validated
104+
end
105+
106+
# Validate reason text
107+
def validate_reason(reason)
108+
return true if reason.nil? || reason.empty?
109+
return error_response('Reason too long') if reason.length > MAX_REASON_LENGTH
110+
111+
# Sanitize to prevent XSS/injection
112+
sanitized = reason.gsub(/[<>"']/, '')
113+
return error_response('Reason contains invalid characters') if sanitized != reason
114+
115+
true
116+
end
117+
118+
# Sanitize JSON body to prevent injection
119+
def sanitize_json_body(body)
120+
return {} if body.nil? || body.empty?
121+
122+
begin
123+
parsed = JSON.parse(body)
124+
return error_response('Request body must be a JSON object') unless parsed.is_a?(Hash)
125+
126+
# Limit depth and size to prevent DoS
127+
return error_response('Request body too complex') if json_depth(parsed) > 5
128+
return error_response('Request body too large') if body.length > 10_240 # 10KB max
129+
130+
parsed
131+
rescue JSON::ParserError => e
132+
error_response("Invalid JSON: #{e.message}")
133+
end
134+
end
135+
136+
# Check if validation result is an error
137+
def validation_error?(result)
138+
result.is_a?(Hash) && result['ok'] == false
139+
end
140+
141+
private
142+
143+
def error_response(message)
144+
{ 'ok' => false, 'error' => message }
145+
end
146+
147+
def json_depth(obj, depth = 0)
148+
return depth unless obj.is_a?(Hash) || obj.is_a?(Array)
149+
return depth + 1 if obj.empty?
150+
151+
if obj.is_a?(Hash)
152+
depth + 1 + obj.values.map { |v| json_depth(v, 0) }.max
153+
else
154+
depth + 1 + obj.map { |v| json_depth(v, 0) }.max
155+
end
156+
end
157+
end
158+
end
159+
end

lib/vmpooler/api/rate_limiter.rb

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
module Vmpooler
4+
class API
5+
# Rate limiter middleware to protect against abuse
6+
# Uses Redis to track request counts per IP and token
7+
class RateLimiter
8+
DEFAULT_LIMITS = {
9+
global_per_ip: { limit: 100, period: 60 }, # 100 requests per minute per IP
10+
authenticated: { limit: 500, period: 60 }, # 500 requests per minute with token
11+
vm_creation: { limit: 20, period: 60 }, # 20 VM creations per minute
12+
vm_deletion: { limit: 50, period: 60 } # 50 VM deletions per minute
13+
}.freeze
14+
15+
def initialize(app, redis, config = {})
16+
@app = app
17+
@redis = redis
18+
@config = DEFAULT_LIMITS.merge(config[:rate_limits] || {})
19+
@enabled = config.fetch(:rate_limiting_enabled, true)
20+
end
21+
22+
def call(env)
23+
return @app.call(env) unless @enabled
24+
25+
request = Rack::Request.new(env)
26+
client_id = identify_client(request)
27+
endpoint_type = classify_endpoint(request)
28+
29+
# Check rate limits
30+
return rate_limit_response(client_id, endpoint_type) if rate_limit_exceeded?(client_id, endpoint_type, request)
31+
32+
# Track the request
33+
increment_request_count(client_id, endpoint_type)
34+
35+
@app.call(env)
36+
end
37+
38+
private
39+
40+
def identify_client(request)
41+
# Prioritize token-based identification for authenticated requests
42+
token = request.env['HTTP_X_AUTH_TOKEN']
43+
return "token:#{token}" if token && !token.empty?
44+
45+
# Fall back to IP address
46+
ip = request.ip || request.env['REMOTE_ADDR'] || 'unknown'
47+
"ip:#{ip}"
48+
end
49+
50+
def classify_endpoint(request)
51+
path = request.path
52+
method = request.request_method
53+
54+
return :vm_creation if method == 'POST' && path.include?('/vm')
55+
return :vm_deletion if method == 'DELETE' && path.include?('/vm')
56+
return :authenticated if request.env['HTTP_X_AUTH_TOKEN']
57+
58+
:global_per_ip
59+
end
60+
61+
def rate_limit_exceeded?(client_id, endpoint_type, _request)
62+
limit_config = @config[endpoint_type] || @config[:global_per_ip]
63+
key = "vmpooler__ratelimit__#{endpoint_type}__#{client_id}"
64+
65+
current_count = @redis.get(key).to_i
66+
current_count >= limit_config[:limit]
67+
rescue StandardError => e
68+
# If Redis fails, allow the request through (fail open)
69+
warn "Rate limiter Redis error: #{e.message}"
70+
false
71+
end
72+
73+
def increment_request_count(client_id, endpoint_type)
74+
limit_config = @config[endpoint_type] || @config[:global_per_ip]
75+
key = "vmpooler__ratelimit__#{endpoint_type}__#{client_id}"
76+
77+
@redis.pipelined do |pipeline|
78+
pipeline.incr(key)
79+
pipeline.expire(key, limit_config[:period])
80+
end
81+
rescue StandardError => e
82+
# Log error but don't fail the request
83+
warn "Rate limiter increment error: #{e.message}"
84+
end
85+
86+
def rate_limit_response(client_id, endpoint_type)
87+
limit_config = @config[endpoint_type] || @config[:global_per_ip]
88+
key = "vmpooler__ratelimit__#{endpoint_type}__#{client_id}"
89+
90+
begin
91+
ttl = @redis.ttl(key)
92+
rescue StandardError
93+
ttl = limit_config[:period]
94+
end
95+
96+
headers = {
97+
'Content-Type' => 'application/json',
98+
'X-RateLimit-Limit' => limit_config[:limit].to_s,
99+
'X-RateLimit-Remaining' => '0',
100+
'X-RateLimit-Reset' => (Time.now.to_i + ttl).to_s,
101+
'Retry-After' => ttl.to_s
102+
}
103+
104+
body = JSON.pretty_generate({
105+
'ok' => false,
106+
'error' => 'Rate limit exceeded',
107+
'limit' => limit_config[:limit],
108+
'period' => limit_config[:period],
109+
'retry_after' => ttl
110+
})
111+
112+
[429, headers, [body]]
113+
end
114+
end
115+
end
116+
end

0 commit comments

Comments
 (0)