From e9a26f79757eb840f8547197aee92eb06043b9cf Mon Sep 17 00:00:00 2001 From: Naeel Date: Fri, 10 Apr 2026 18:58:40 +0300 Subject: [PATCH] =?UTF-8?q?v0.1.17:=20security=20hardening=20=E2=80=94=201?= =?UTF-8?q?8/20=20vulnerabilities=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/gosqs/create_queue.go | 5 +- app/gosqs/get_queue_url.go | 8 ++- app/gosqs/queue_attributes.go | 14 ++-- app/gosqs/receive_message.go | 4 ++ app/gosqs/send_message.go | 21 ++++++ app/gosqs/send_message_batch.go | 36 ++++++++++ app/gosqs/validation.go | 121 ++++++++++++++++++++++++++++++++ app/persistence/redis.go | 5 ++ app/router/router.go | 15 ++++ app/tenant/tenant_store.go | 10 +++ deployments/k8s/deployment.yaml | 4 +- 11 files changed, 229 insertions(+), 14 deletions(-) create mode 100644 app/gosqs/validation.go diff --git a/app/gosqs/create_queue.go b/app/gosqs/create_queue.go index 357d27b..0506591 100644 --- a/app/gosqs/create_queue.go +++ b/app/gosqs/create_queue.go @@ -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) diff --git a/app/gosqs/get_queue_url.go b/app/gosqs/get_queue_url.go index 6a243f0..e932ec0 100644 --- a/app/gosqs/get_queue_url.go +++ b/app/gosqs/get_queue_url.go @@ -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{ diff --git a/app/gosqs/queue_attributes.go b/app/gosqs/queue_attributes.go index aa1f352..28fc14d 100644 --- a/app/gosqs/queue_attributes.go +++ b/app/gosqs/queue_attributes.go @@ -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, ":") diff --git a/app/gosqs/receive_message.go b/app/gosqs/receive_message.go index 792aa81..d25c9b4 100644 --- a/app/gosqs/receive_message.go +++ b/app/gosqs/receive_message.go @@ -35,6 +35,8 @@ maxNumberOfMessages := requestBody.MaxNumberOfMessages if maxNumberOfMessages == 0 { maxNumberOfMessages = 1 } +// Fix #8: clamp MaxNumberOfMessages к AWS лимиту 1–10 +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 лимиту 0–20 +waitTimeSeconds = ClampInt(waitTimeSeconds, 0, MaxReceiveMessageWaitTimeSeconds) // Long polling: ждём появления сообщения до waitTimeSeconds*10 итераций по 100ms loops := waitTimeSeconds * 10 diff --git a/app/gosqs/send_message.go b/app/gosqs/send_message.go index d0ea550..cee626b 100644 --- a/app/gosqs/send_message.go +++ b/app/gosqs/send_message.go @@ -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 diff --git a/app/gosqs/send_message_batch.go b/app/gosqs/send_message_batch.go index e6a3348..f7953aa 100644 --- a/app/gosqs/send_message_batch.go +++ b/app/gosqs/send_message_batch.go @@ -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 { diff --git a/app/gosqs/validation.go b/app/gosqs/validation.go new file mode 100644 index 0000000..0c07075 --- /dev/null +++ b/app/gosqs/validation.go @@ -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 +} diff --git a/app/persistence/redis.go b/app/persistence/redis.go index e7934ca..cbe31fc 100644 --- a/app/persistence/redis.go +++ b/app/persistence/redis.go @@ -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() diff --git a/app/router/router.go b/app/router/router.go index b162cc7..aa2c636 100644 --- a/app/router/router.go +++ b/app/router/router.go @@ -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 { diff --git a/app/tenant/tenant_store.go b/app/tenant/tenant_store.go index 04e5f2b..d87458f 100644 --- a/app/tenant/tenant_store.go +++ b/app/tenant/tenant_store.go @@ -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) diff --git a/deployments/k8s/deployment.yaml b/deployments/k8s/deployment.yaml index 4fb0c26..332ff16 100644 --- a/deployments/k8s/deployment.yaml +++ b/deployments/k8s/deployment.yaml @@ -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