v5.0.71: rawQuery (no %2F) + Accept: */* (DDoS-Guard POST fix)
This commit is contained in:
@@ -148,3 +148,145 @@ if os.Getenv("NUBES_DEBUG_HTTP") == "1" {
|
|||||||
|
|
||||||
Создано задач: 3
|
Создано задач: 3
|
||||||
|
|
||||||
|
|
||||||
|
Вопрос Соннету:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Контекст**: Terraform Provider на Go. API: ColdFusion за DDoS-Guard.
|
||||||
|
|
||||||
|
**Что сделали**:
|
||||||
|
- `TLSNextProto = make(map[string]...)` — GET работает (200 JSON)
|
||||||
|
- `?endpoint=` формат URL — как у генератора
|
||||||
|
- `User-Agent: Mozilla/5.0`
|
||||||
|
|
||||||
|
**Проблема**: GET → 200. POST → 403. curl (GET и POST) → оба 200.
|
||||||
|
|
||||||
|
**Код POST**:
|
||||||
|
```go
|
||||||
|
func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) {
|
||||||
|
// payload — json-сериализованная структура
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, method, c.ApiEndpoint, body)
|
||||||
|
// ?endpoint=/instances
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("endpoint", path)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Работающий curl**:
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "User-Agent: Mozilla/5.0" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"serviceId":13}' \
|
||||||
|
"https://deck-api-test.ngcloud.ru/api/v1/index.cfm?endpoint=/instances"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вопрос**: Что в Go `http.Client` (c `TLSNextProto = make(...)`) может вызывать 403 на POST, при том что GET работает, и curl POST тоже работает? Может ли `http.NewRequestWithContext` добавлять заголовки/байты, которые триггерят DDoS-Guard на POST но не на GET?
|
||||||
|
|
||||||
|
Список задач обновлен
|
||||||
|
|
||||||
|
Прочитаю актуальный код `doRequest` и инициализацию transport, чтобы ответить точно, а не по памяти:
|
||||||
|
|
||||||
|
Read [](file:///home/naeel/tf_provider/universal_rebuild/internal/core/client.go#804-804), lines 804 to 880
|
||||||
|
|
||||||
|
Read [](file:///home/naeel/tf_provider/universal_rebuild/internal/provider/provider.go#122-122), lines 122 to 155
|
||||||
|
|
||||||
|
## Диагноз: 3 кандидата, #1 самый вероятный
|
||||||
|
|
||||||
|
Код виден полностью. Вот что реально отличает Go POST от curl POST.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #1 (главный подозреваемый): `url.Values.Encode()` кодирует `/` как `%2F`
|
||||||
|
|
||||||
|
```go
|
||||||
|
q.Set("endpoint", endpointPath) // endpointPath = "/instances"
|
||||||
|
req.URL.RawQuery = q.Encode() // → endpoint=%2Finstances
|
||||||
|
```
|
||||||
|
|
||||||
|
curl отправляет: `?endpoint=/instances`
|
||||||
|
Go отправляет: `?endpoint=%2Finstances`
|
||||||
|
|
||||||
|
**Почему GET проходит, а POST нет?** DDoS-Guard WAF для POST-запросов почти всегда имеет **allowlist эндпоинтов** — проверяет, что `endpoint` соответствует разрешённым путям. `/instances` совпадает, `%2Finstances` — нет → 403. GET-запросы проверяются слабее (read-only, меньший риск).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #2: Нет заголовка `Accept`
|
||||||
|
|
||||||
|
curl всегда добавляет `Accept: */*` автоматически.
|
||||||
|
Ваш Go-код его не ставит.
|
||||||
|
|
||||||
|
DDoS-Guard fingerprinting видит: POST с телом + нет `Accept` → нет `Accept` = нет браузера = потенциальный бот.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #3: `Accept-Encoding: gzip` добавляет transport
|
||||||
|
|
||||||
|
Т.к. `DisableCompression` не выставлен, transport добавляет `Accept-Encoding: gzip` ко всем запросам. Для GET это тоже есть → GET проходит → вероятно, не причина. Но на всякий случай стоит проверить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Как проверить немедленно
|
||||||
|
|
||||||
|
`DumpRequestOut` уже в коде:
|
||||||
|
```bash
|
||||||
|
NUBES_DEBUG_HTTP=1 terraform apply 2>&1 | grep -A 30 ">>> REQ POST"
|
||||||
|
```
|
||||||
|
И сравнить с:
|
||||||
|
```bash
|
||||||
|
curl -v -X POST -H "User-Agent: Mozilla/5.0" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"serviceId":13}' \
|
||||||
|
"https://deck-api-test.ngcloud.ru/api/v1/index.cfm?endpoint=/instances" 2>&1 | grep -E "^>|^<"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Что менять
|
||||||
|
|
||||||
|
**Fix #1 — не использовать `q.Encode()` для endpoint:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Вместо q.Set("endpoint",...) + q.Encode()
|
||||||
|
rawQuery := "endpoint=" + endpointPath // слэш не кодируется
|
||||||
|
if extraQuery != "" {
|
||||||
|
rawQuery += "&" + extraQuery
|
||||||
|
}
|
||||||
|
req.URL.RawQuery = rawQuery
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix #2 — добавить `Accept: */*`** (как curl):
|
||||||
|
|
||||||
|
```go
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix #3 (опционально) — отключить автоматическое gzip в transport:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
transport.DisableCompression = true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Итог
|
||||||
|
|
||||||
|
| Причина | Почему GET ок | Почему POST 403 |
|
||||||
|
|---|---|---|
|
||||||
|
| `%2Finstances` в URL | WAF для GET мягче | WAF для POST проверяет allowlist путей |
|
||||||
|
| Нет `Accept` | То же | Browser fingerprint не проходит |
|
||||||
|
| `Connection: close` | - | (здесь `req.Close = true` нет, уже убрано) |
|
||||||
|
|
||||||
|
Начинать с Fix #1 — вероятность ~70% что это именно оно.
|
||||||
|
|
||||||
|
Создано задач: 4
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
nubes = {
|
nubes = {
|
||||||
source = "terra.k8c.ru/nubes-test/nubes"
|
source = "terra.k8c.ru/nubes-test/nubes"
|
||||||
version = "5.0.70"
|
version = "5.0.71"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -833,27 +834,21 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ?endpoint= формат — как генератор (Python) и curl
|
// ?endpoint= формат — без url.Values.Encode (сохраняет / как /)
|
||||||
endpointPath := path
|
endpointPath := path
|
||||||
extraQuery := ""
|
extraQuery := ""
|
||||||
if idx := strings.Index(path, "?"); idx >= 0 {
|
if idx := strings.Index(path, "?"); idx >= 0 {
|
||||||
endpointPath = path[:idx]
|
endpointPath = path[:idx]
|
||||||
extraQuery = path[idx+1:]
|
extraQuery = path[idx+1:]
|
||||||
}
|
}
|
||||||
q := req.URL.Query()
|
rawQuery := "endpoint=" + endpointPath
|
||||||
q.Set("endpoint", endpointPath)
|
|
||||||
if extraQuery != "" {
|
if extraQuery != "" {
|
||||||
// Добавляем параметры из path в URL (не в endpoint)
|
rawQuery += "&" + extraQuery
|
||||||
for _, param := range strings.Split(extraQuery, "&") {
|
|
||||||
kv := strings.SplitN(param, "=", 2)
|
|
||||||
if len(kv) == 2 {
|
|
||||||
q.Set(kv[0], kv[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
req.URL.RawQuery = q.Encode()
|
req.URL.RawQuery = rawQuery
|
||||||
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
req.Header.Set("Accept", "*/*")
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
@@ -861,6 +856,12 @@ func (c *UniversalClient) doRequest(ctx context.Context, method, path string, pa
|
|||||||
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
|
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DEBUG
|
||||||
|
if os.Getenv("NUBES_DEBUG_HTTP") == "1" {
|
||||||
|
dump, _ := httputil.DumpRequestOut(req, body != nil)
|
||||||
|
fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.HttpClient.Do(req)
|
resp, err := c.HttpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
version string = "5.0.70"
|
version string = "5.0.71"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
Reference in New Issue
Block a user