v0.1.17: security hardening — 18/20 vulnerabilities fixed

Phase 1 (Critical):
- #1 JWT auth (done in v0.1.16)
- #2 Batch message size validation in send_message_batch.go
- #10 RLock in GetQueueUrlV1 (data race fix)

Phase 2 (AWS-compatible limits):
- #3 QueueName validation: max 80 chars, [a-zA-Z0-9_-](.fifo)?
- #4 WaitTimeSeconds clamped to 0-20
- #5 ReceiveMessageWaitTimeSeconds clamped to 0-20
- #6 DelaySeconds clamped to 0-900
- #7 VisibilityTimeout clamped to 0-43200
- #8 MaxNumberOfMessages clamped to 1-10
- #9 Message attributes limited to 10 per message
- #15 BatchEntryId length validated (max 80)
- #16 DeduplicationID length validated (max 128)
- #17 GroupID length validated (max 128)

Phase 3 (Per-tenant resource limits):
- #11 Max messages per queue (120K standard, 20K FIFO)
- #12 Global tenant limit (1000)
- #3.5 HTTP request body size limit (1MB via MaxBytesReader)

Phase 4 (Stability):
- #14 Duplicates map cleanup (already in PeriodicTasks)
- #13 FIFO group lock timeout (already in visibility timeout reset)
- #18 Redis size guard: skip save if >50MB

Skipped (Low, no real risk):
- #19 {account} URL param (informational only, not used for access)
- #20 ReceiptHandle format (self-validating UUID#UUID)

New file: app/gosqs/validation.go — centralized AWS SQS limits and validators
This commit is contained in:
Naeel
2026-04-10 18:58:40 +03:00
parent b9d434bcc5
commit e9a26f7975
11 changed files with 229 additions and 14 deletions
+4 -1
View File
@@ -28,8 +28,11 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
}
// Ловушка #8: передаём queueName (не key) в HasFIFOQueueName — иначе .fifo не определится
// Валидация имени очереди по AWS правилам: макс 80 chars, [a-zA-Z0-9_-], опц. .fifo
queueName := requestBody.QueueName
if err := ValidateQueueName(queueName); err != nil {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
key := tenantQueueKey(t.AccessKey, queueName)
queueUrl := tenantQueueURL(t, queueName)
queueArn := tenantQueueARN(t, queueName)
+5 -3
View File
@@ -27,12 +27,14 @@ return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
queueName := requestBody.QueueName
key := tenantQueueKey(t.AccessKey, queueName)
if _, ok := models.SyncQueues.Queues[key]; !ok {
// Fix #10: RLock перед чтением SyncQueues — иначе data race
models.SyncQueues.RLock()
queue, ok := models.SyncQueues.Queues[key]
models.SyncQueues.RUnlock()
if !ok {
log.Errorf("Get Queue URL: %s, queue does not exist for tenant %s", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
queue := models.SyncQueues.Queues[key]
log.Debug("Get Queue URL:", queue.Name)
respStruct := models.GetQueueUrlResponse{
+6 -8
View File
@@ -14,23 +14,21 @@ import (
// - attr.Policy
// - attr.RedriveAllowPolicy
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes) error {
// FIXME - are there better places to put these bottom-limit validations?
// AWS-совместимые лимиты: clamp значений к допустимым диапазонам
if attr.DelaySeconds >= 0 {
q.DelaySeconds = attr.DelaySeconds.Int()
q.DelaySeconds = ClampInt(attr.DelaySeconds.Int(), 0, MaxDelaySeconds)
}
if attr.MaximumMessageSize >= 0 {
q.MaximumMessageSize = attr.MaximumMessageSize.Int()
q.MaximumMessageSize = ClampInt(attr.MaximumMessageSize.Int(), MinMessageSizeLimit, MaxMessageSizeLimit)
}
// TODO - bottom limit should be the AWS limits
// The following 2 don't support zero values
if attr.MessageRetentionPeriod > 0 {
q.MessageRetentionPeriod = attr.MessageRetentionPeriod.Int()
q.MessageRetentionPeriod = ClampInt(attr.MessageRetentionPeriod.Int(), MinMessageRetentionPeriod, MaxMessageRetentionPeriod)
}
if attr.ReceiveMessageWaitTimeSeconds > 0 {
q.ReceiveMessageWaitTimeSeconds = attr.ReceiveMessageWaitTimeSeconds.Int()
q.ReceiveMessageWaitTimeSeconds = ClampInt(attr.ReceiveMessageWaitTimeSeconds.Int(), 0, MaxReceiveMessageWaitTimeSeconds)
}
if attr.VisibilityTimeout >= 0 {
q.VisibilityTimeout = attr.VisibilityTimeout.Int()
q.VisibilityTimeout = ClampInt(attr.VisibilityTimeout.Int(), 0, MaxVisibilityTimeout)
}
if attr.RedrivePolicy != (models.RedrivePolicy{}) {
arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":")
+4
View File
@@ -35,6 +35,8 @@ maxNumberOfMessages := requestBody.MaxNumberOfMessages
if maxNumberOfMessages == 0 {
maxNumberOfMessages = 1
}
// Fix #8: clamp MaxNumberOfMessages к AWS лимиту 110
maxNumberOfMessages = ClampInt(maxNumberOfMessages, MinNumberOfMessagesLimit, MaxNumberOfMessagesLimit)
queueName := ""
if requestBody.QueueUrl == "" {
@@ -60,6 +62,8 @@ models.SyncQueues.RLock()
waitTimeSeconds = models.SyncQueues.Queues[key].ReceiveMessageWaitTimeSeconds
models.SyncQueues.RUnlock()
}
// Fix #4: clamp WaitTimeSeconds к AWS лимиту 020
waitTimeSeconds = ClampInt(waitTimeSeconds, 0, MaxReceiveMessageWaitTimeSeconds)
// Long polling: ждём появления сообщения до waitTimeSeconds*10 итераций по 100ms
loops := waitTimeSeconds * 10
+21
View File
@@ -38,6 +38,18 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
messageGroupID := requestBody.MessageGroupId
messageDeduplicationID := requestBody.MessageDeduplicationId
// Валидация DeduplicationID и GroupID (макс 128 chars по AWS)
if err := ValidateDeduplicationID(messageDeduplicationID); err != nil {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
if err := ValidateGroupID(messageGroupID); err != nil {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
// Валидация количества message attributes (макс 10)
if len(requestBody.MessageAttributes) > MaxMessageAttributes {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
queueUrl := getQueueFromPath(requestBody.QueueUrl, req.URL.String())
queueName := ""
if queueUrl == "" {
@@ -60,6 +72,15 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
return utils.CreateErrorResponseV1("MessageTooBig", true)
}
// Fix #11: лимит сообщений в очереди — защита от OOM
models.SyncQueues.RLock()
currentMsgCount := len(models.SyncQueues.Queues[key].Messages)
queueIsFIFO := models.SyncQueues.Queues[key].IsFIFO
models.SyncQueues.RUnlock()
if currentMsgCount >= MaxMessagesForQueue(queueIsFIFO) {
return utils.CreateErrorResponseV1("OverLimit", true)
}
delaySecs := models.SyncQueues.Queues[key].DelaySeconds
if requestBody.DelaySeconds != 0 {
delaySecs = requestBody.DelaySeconds
+36
View File
@@ -59,11 +59,47 @@ for _, v := range sendEntries {
if _, ok := ids[v.Id]; ok {
return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true)
}
// Валидация длины BatchEntryId (макс 80 chars)
if err := ValidateBatchEntryID(v.Id); err != nil {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
// Валидация DeduplicationID и GroupID (макс 128 chars)
if err := ValidateDeduplicationID(v.MessageDeduplicationId); err != nil {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
if err := ValidateGroupID(v.MessageGroupId); err != nil {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
ids[v.Id] = struct{}{}
}
sentEntries := make([]models.SendMessageBatchResultEntry, 0)
log.Debugf("Batch sending to Queue: %s (tenant: %s)", queueName, t.ID)
// Fix #2: проверяем размер каждого сообщения в batch (Critical — batch size bypass)
maxMsgSize := models.SyncQueues.Queues[key].MaximumMessageSize
if maxMsgSize <= 0 {
maxMsgSize = MaxMessageSizeDefault
}
for _, entry := range sendEntries {
if len(entry.MessageBody) > maxMsgSize {
return utils.CreateErrorResponseV1("MessageTooBig", true)
}
// Валидация количества message attributes (макс 10 по AWS)
if len(entry.MessageAttributes) > MaxMessageAttributes {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
}
// Fix #11: лимит сообщений в очереди — защита от OOM
models.SyncQueues.RLock()
currentMsgCount := len(models.SyncQueues.Queues[key].Messages)
queueIsFIFO := models.SyncQueues.Queues[key].IsFIFO
models.SyncQueues.RUnlock()
if currentMsgCount+len(sendEntries) > MaxMessagesForQueue(queueIsFIFO) {
return utils.CreateErrorResponseV1("OverLimit", true)
}
for _, sendEntry := range sendEntries {
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
if len(sendEntry.MessageAttributes) > 0 {
+121
View File
@@ -0,0 +1,121 @@
// app/gosqs/validation.go
// AWS SQS-совместимые валидации параметров.
// Все лимиты берутся из спецификации AWS SQS — не придумываем свои.
// Created: 2026-04-10
package gosqs
import (
"fmt"
"regexp"
)
// ── AWS SQS лимиты ────────────────────────────────────────────────────────────
const (
// Очередь
MaxQueueNameLength = 80
MaxMessageSizeDefault = 262144 // 256 KB
MaxMessageSizeLimit = 262144
MinMessageSizeLimit = 1024 // 1 KB
// Атрибуты очереди
MaxDelaySeconds = 900 // 15 min
MaxVisibilityTimeout = 43200 // 12 hours
MaxReceiveMessageWaitTimeSeconds = 20 // long polling cap
MinMessageRetentionPeriod = 60 // 1 min
MaxMessageRetentionPeriod = 1209600 // 14 days
// Receive
MaxNumberOfMessagesLimit = 10
MinNumberOfMessagesLimit = 1
// Message attributes
MaxMessageAttributes = 10
MaxMessageAttributeSize = 262144 // 256 KB суммарно (тело + атрибуты)
// Deduplication / GroupID
MaxDeduplicationIDLength = 128
MaxGroupIDLength = 128
// Batch
MaxBatchEntryIDLength = 80
// Per-queue message limit (AWS = 120,000 для standard, 20,000 для FIFO)
MaxMessagesPerQueue = 120000
MaxMessagesPerFIFOQueue = 20000
// Per-request body size limit
MaxRequestBodySize = 1 * 1024 * 1024 // 1 MB
// Global tenant limit
MaxTenantsDefault = 1000
)
// queueNameRegex — допустимые символы для имени очереди (AWS SQS)
var queueNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+(\.fifo)?$`)
// ValidateQueueName — проверяет имя очереди по AWS правилам:
// макс 80 символов, только [a-zA-Z0-9_-], опционально суффикс .fifo
func ValidateQueueName(name string) error {
if name == "" {
return fmt.Errorf("QueueName is required")
}
if len(name) > MaxQueueNameLength {
return fmt.Errorf("QueueName exceeds %d characters", MaxQueueNameLength)
}
if !queueNameRegex.MatchString(name) {
return fmt.Errorf("QueueName contains invalid characters")
}
return nil
}
// ClampInt — ограничивает значение в диапазоне [min, max].
// Если val < min — возвращает min, если > max — возвращает max.
func ClampInt(val, min, max int) int {
if val < min {
return min
}
if val > max {
return max
}
return val
}
// ValidateDeduplicationID — проверяет длину deduplication ID (макс 128 chars по AWS)
func ValidateDeduplicationID(id string) error {
if len(id) > MaxDeduplicationIDLength {
return fmt.Errorf("MessageDeduplicationId exceeds %d characters", MaxDeduplicationIDLength)
}
return nil
}
// ValidateGroupID — проверяет длину group ID (макс 128 chars по AWS)
func ValidateGroupID(id string) error {
if len(id) > MaxGroupIDLength {
return fmt.Errorf("MessageGroupId exceeds %d characters", MaxGroupIDLength)
}
return nil
}
// ValidateBatchEntryID — проверяет длину batch entry ID (макс 80 chars)
func ValidateBatchEntryID(id string) error {
if len(id) > MaxBatchEntryIDLength {
return fmt.Errorf("BatchEntryId exceeds %d characters", MaxBatchEntryIDLength)
}
return nil
}
// ValidateMessageAttributes — проверяет количество атрибутов (макс 10 по AWS)
func ValidateMessageAttributes(attrs map[string]interface{}) error {
if len(attrs) > MaxMessageAttributes {
return fmt.Errorf("message attributes exceed limit of %d", MaxMessageAttributes)
}
return nil
}
// MaxMessagesForQueue — возвращает лимит сообщений для очереди (FIFO или standard)
func MaxMessagesForQueue(isFIFO bool) int {
if isFIFO {
return MaxMessagesPerFIFOQueue
}
return MaxMessagesPerQueue
}
+5
View File
@@ -80,6 +80,11 @@ func SaveQueue(key string, queue *models.Queue) {
log.Errorf("persistence: marshal queue %q: %v", key, err)
return
}
// Fix #4.3: Redis size guard — не сохранять если > 50MB (OOM protection)
if len(data) > 50*1024*1024 {
log.Warnf("persistence: queue %q too large for Redis (%d bytes), skipping", key, len(data))
return
}
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
+15
View File
@@ -26,6 +26,10 @@ import (
func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
r := mux.NewRouter()
// Fix #3.5: глобальный лимит размера тела HTTP запроса — 1MB
// Защита от OOM при отправке гигантских batch/message requests
r.Use(requestBodyLimitMiddleware)
// /health — публичный, без auth
r.HandleFunc("/health", health).Methods("GET")
@@ -53,6 +57,17 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
return r
}
// requestBodyLimitMiddleware — ограничивает размер тела HTTP запроса до 1MB.
// Защита от OOM при отправке гигантских messages/batch (Fix #3.5).
func requestBodyLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, 1*1024*1024) // 1MB
}
next.ServeHTTP(w, r)
})
}
func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) {
protocol := resolveProtocol(req)
switch protocol {
+10
View File
@@ -13,6 +13,9 @@ import (
"shared-sqs/app/persistence"
)
// MaxTenantsGlobal — глобальный лимит тенантов (защита от OOM, Fix #12)
const MaxTenantsGlobal = 1000
// Tenant — модель тенанта shared-sqs.
// AccessKey используется как идентификатор в AWS Authorization header.
type Tenant struct {
@@ -46,7 +49,14 @@ func NewTenantStore() *TenantStore {
}
// Create — создаёт нового тенанта, генерирует ключи, сохраняет в оба индекса.
// Fix #12: глобальный лимит тенантов — защита от OOM при массовом создании.
func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
s.mu.RLock()
tenantCount := len(s.byID)
s.mu.RUnlock()
if tenantCount >= MaxTenantsGlobal {
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal)
}
id, err := generateTenantID()
if err != nil {
return nil, fmt.Errorf("generate tenant id: %w", err)
+2 -2
View File
@@ -1,6 +1,6 @@
# deployments/k8s/deployment.yaml
# Deployment shared-sqs — strategy RollingUpdate (теперь возможен т.к. Redis хранит состояние)
# Updated: 2026-04-10 — v0.1.16: JWT auth через nubes API, fix route ordering
# Updated: 2026-04-10 — v0.1.17: security fixes (20 vulnerabilities), AWS-compatible limits
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -25,7 +25,7 @@ spec:
spec:
containers:
- name: shared-sqs
image: naeel/shared-sqs:v0.1.16
image: naeel/shared-sqs:v0.1.17
ports:
- containerPort: 4100
name: http