Fix BGP route leak: mandatory outbound prefix-filter on every neighbor

Prod-инцидент: FRR реэкспортировал входящий 10.0.0.0/8 обратно
eBGP-соседям. Причина — ни у одного соседа не было outbound-фильтра
(no bgp ebgp-requires-policy отключал защиту FRR, единственный
route-map RM-PREPEND-OUT permit-all фильтрации не делал).

frr.conf.j2: добавлен ip prefix-list PL-ANYCAST-OUT (permit только
anycast_prefix) и route-map RM-OUT / RM-OUT-PREPEND (фильтр, либо
фильтр+prepend), оба с явным deny в конце. Назначены outbound на
ВСЕХ соседей без исключения — наружу теперь уходит исключительно
анонсируемый anycast-префикс, что бы ни попало в локальную
BGP-таблицу от других соседей.

Уязвимость структурная (в общей роли), поэтому фикс задел и
площадку A — golden-файлы frr/a{1,2,3}/frr.conf синхронизированы
с новым рендером (намеренно, не регресс). no bgp ebgp-requires-policy
сознательно не тронут — его включение требует ещё inbound-policy на
каждого соседа, отдельная задача.

render-check.sh 6/6, render-check-cloud.sh 84/84 (включая nft -c).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bc3HkMiDwooSyLBLoMKNyF
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-10 17:57:51 +03:00
1 parent 1e287ee81c
commit 804fee4ffa
8 files changed
+113 -9

No files matched your search

+3 -1
View File
@@ -91,6 +91,8 @@ docker compose exec -T c1 curl -s http://10.200.200.1/
Роль `frr_anycast` обобщена: список BGP-соседей узла — не 2 жёстко именованных (`neighbor_b1`/`neighbor_b2`), а `bgp_neighbors` произвольной длины, каждый с флагом `prepend` (as-path prepend на исходящих анонсах). Опционально (`enable_snat: true`) роль также поднимает sNAT через `nftables` (masquerade на внешнем интерфейсе) — оба узла ниже используют эту одну и ту же роль с разными `group_vars`. Роль `frr_anycast` обобщена: список BGP-соседей узла — не 2 жёстко именованных (`neighbor_b1`/`neighbor_b2`), а `bgp_neighbors` произвольной длины, каждый с флагом `prepend` (as-path prepend на исходящих анонсах). Опционально (`enable_snat: true`) роль также поднимает sNAT через `nftables` (masquerade на внешнем интерфейсе) — оба узла ниже используют эту одну и ту же роль с разными `group_vars`.
**Outbound-фильтр обязателен у всех соседей.** Каждому соседу назначен outbound route-map (`RM-OUT` или, если `prepend: true`, `RM-OUT-PREPEND`), матчащий `ip prefix-list PL-ANYCAST-OUT` (permit только `anycast_prefix`) с явным `deny` в конце — наружу уходит исключительно анонсируемый anycast-префикс, что бы ни попало в локальную BGP-таблицу от других соседей. Это фикс реального прод-инцидента: без фильтра FRR реэкспортировал случайно полученный `10.0.0.0/8` обратно в сторону других eBGP-соседей (см. [docs/2026-09-10-bgp-outbound-filter-summary.md](docs/2026-09-10-bgp-outbound-filter-summary.md)).
Детали: [docs/2026-09-03-ansible-site-a-plan.md](docs/2026-09-03-ansible-site-a-plan.md) / [docs/2026-09-03-ansible-site-a-summary.md](docs/2026-09-03-ansible-site-a-summary.md) — исходная реализация; [docs/2026-09-09-ansible-site-cloud-plan.md](docs/2026-09-09-ansible-site-cloud-plan.md) / [docs/2026-09-09-ansible-site-cloud-summary.md](docs/2026-09-09-ansible-site-cloud-summary.md) — доработка под второе окружение (см. ниже). Детали: [docs/2026-09-03-ansible-site-a-plan.md](docs/2026-09-03-ansible-site-a-plan.md) / [docs/2026-09-03-ansible-site-a-summary.md](docs/2026-09-03-ansible-site-a-summary.md) — исходная реализация; [docs/2026-09-09-ansible-site-cloud-plan.md](docs/2026-09-09-ansible-site-cloud-plan.md) / [docs/2026-09-09-ansible-site-cloud-summary.md](docs/2026-09-09-ansible-site-cloud-summary.md) — доработка под второе окружение (см. ниже).
### Структура ### Структура
@@ -160,6 +162,6 @@ ansible/tests/render-check.sh # регресс: площадка A ре
ansible/tests/render-check-cloud.sh # 4 соседа, prepend только на резервном канале, sNAT ansible/tests/render-check-cloud.sh # 4 соседа, prepend только на резервном канале, sNAT
``` ```
`render-check-cloud.sh` проверяет: у каждого из 4 узлов ровно 4 `neighbor ... remote-as` (2×65104 без route-map, 2×65103 с `route-map RM-PREPEND-OUT out`), `route-map` содержит `set as-path prepend 65200 65200 65200`, `nftables.conf` содержит DNAT (`dnat to 95.163.53.117`) и `masquerade` на заданном внешнем интерфейсе, а также (если `nft` доступен на control node) валидирует синтаксис каждого `nftables.conf` через `nft -c -f`. `render-check-cloud.sh` проверяет: у каждого из 4 узлов ровно 4 `neighbor ... remote-as`, outbound `route-map` навешан на всех — `RM-OUT-PREPEND` (фильтр + `set as-path prepend 65200 65200 65200`) на 2×65103, `RM-OUT` (только фильтр) на 2×65104, `nftables.conf` содержит DNAT (`dnat to 95.163.53.117`) и `masquerade` на заданном внешнем интерфейсе, а также (если `nft` доступен на control node) валидирует синтаксис каждого `nftables.conf` через `nft -c -f`.
Детали и разбор диаграммы: [docs/2026-09-09-ansible-site-cloud-plan.md](docs/2026-09-09-ansible-site-cloud-plan.md) / [docs/2026-09-09-ansible-site-cloud-summary.md](docs/2026-09-09-ansible-site-cloud-summary.md). Детали и разбор диаграммы: [docs/2026-09-09-ansible-site-cloud-plan.md](docs/2026-09-09-ansible-site-cloud-plan.md) / [docs/2026-09-09-ansible-site-cloud-summary.md](docs/2026-09-09-ansible-site-cloud-summary.md).
@@ -4,10 +4,20 @@ hostname {{ inventory_hostname }}
log stdout informational log stdout informational
no ipv6 forwarding no ipv6 forwarding
! !
ip prefix-list PL-ANYCAST-OUT seq 5 permit {{ anycast_prefix }}
!
route-map RM-OUT permit 10
match ip address prefix-list PL-ANYCAST-OUT
!
route-map RM-OUT deny 20
!
{% if bgp_neighbors | selectattr('prepend') | list | length > 0 %} {% if bgp_neighbors | selectattr('prepend') | list | length > 0 %}
route-map RM-PREPEND-OUT permit 10 route-map RM-OUT-PREPEND permit 10
match ip address prefix-list PL-ANYCAST-OUT
set as-path prepend {{ ((local_asn ~ ' ') * (as_path_prepend_count | default(0) | int)) | trim }} set as-path prepend {{ ((local_asn ~ ' ') * (as_path_prepend_count | default(0) | int)) | trim }}
! !
route-map RM-OUT-PREPEND deny 20
!
{% endif %} {% endif %}
router bgp {{ local_asn }} router bgp {{ local_asn }}
bgp router-id {{ router_id }} bgp router-id {{ router_id }}
@@ -21,8 +31,8 @@ router bgp {{ local_asn }}
{% for n in bgp_neighbors %} {% for n in bgp_neighbors %}
neighbor {{ n.ip }} activate neighbor {{ n.ip }} activate
{% endfor %} {% endfor %}
{% for n in bgp_neighbors if n.prepend %} {% for n in bgp_neighbors %}
neighbor {{ n.ip }} route-map RM-PREPEND-OUT out neighbor {{ n.ip }} route-map {{ 'RM-OUT-PREPEND' if n.prepend else 'RM-OUT' }} out
{% endfor %} {% endfor %}
exit-address-family exit-address-family
! !
+10 -5
View File
@@ -43,12 +43,17 @@ for n in vr01 vr02 vr03 vr04; do
check "$n neighbor main2" "$f" " neighbor $MAIN2_NEIGHBOR_IP remote-as 65104" 1 check "$n neighbor main2" "$f" " neighbor $MAIN2_NEIGHBOR_IP remote-as 65104" 1
check "$n neighbor backup1" "$f" " neighbor $BACKUP1_NEIGHBOR_IP remote-as 65103" 1 check "$n neighbor backup1" "$f" " neighbor $BACKUP1_NEIGHBOR_IP remote-as 65103" 1
check "$n neighbor backup2" "$f" " neighbor $BACKUP2_NEIGHBOR_IP remote-as 65103" 1 check "$n neighbor backup2" "$f" " neighbor $BACKUP2_NEIGHBOR_IP remote-as 65103" 1
check "$n route-map defined" "$f" "route-map RM-PREPEND-OUT permit 10" 1 check "$n prefix-list anycast only" "$f" "ip prefix-list PL-ANYCAST-OUT seq 5 permit 172.31.255.10/32" 1
check "$n RM-OUT filters to anycast" "$f" "match ip address prefix-list PL-ANYCAST-OUT" 2 # RM-OUT + RM-OUT-PREPEND
check "$n RM-OUT implicit deny" "$f" "route-map RM-OUT deny 20" 1
check "$n RM-OUT-PREPEND defined" "$f" "route-map RM-OUT-PREPEND permit 10" 1
check "$n prepend value" "$f" "set as-path prepend 65200 65200 65200" 1 check "$n prepend value" "$f" "set as-path prepend 65200 65200 65200" 1
check "$n route-map on backup1 only" "$f" "neighbor $BACKUP1_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 1 check "$n filter+prepend on backup1" "$f" "neighbor $BACKUP1_NEIGHBOR_IP route-map RM-OUT-PREPEND out" 1
check "$n route-map on backup2 only" "$f" "neighbor $BACKUP2_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 1 check "$n filter+prepend on backup2" "$f" "neighbor $BACKUP2_NEIGHBOR_IP route-map RM-OUT-PREPEND out" 1
check "$n NO route-map on main1" "$f" "neighbor $MAIN1_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 0 check "$n filter-only on main1" "$f" "neighbor $MAIN1_NEIGHBOR_IP route-map RM-OUT out" 1
check "$n NO route-map on main2" "$f" "neighbor $MAIN2_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 0 check "$n filter-only on main2" "$f" "neighbor $MAIN2_NEIGHBOR_IP route-map RM-OUT out" 1
check "$n NO prepend route-map on main1" "$f" "neighbor $MAIN1_NEIGHBOR_IP route-map RM-OUT-PREPEND out" 0
check "$n NO prepend route-map on main2" "$f" "neighbor $MAIN2_NEIGHBOR_IP route-map RM-OUT-PREPEND out" 0
nft="$OUT_DIR/$n/nftables.conf" nft="$OUT_DIR/$n/nftables.conf"
check "$n nftables masquerade" "$nft" "masquerade" 1 check "$n nftables masquerade" "$nft" "masquerade" 1
@@ -0,0 +1,23 @@
# План внедрения: outbound-фильтр BGP (устранение утечки 10.0.0.0/8)
**Дата:** 2026-09-10
## Проблема
В PROD (окружение `site-cloud`) FRR реэкспортировал входящий маршрут `10.0.0.0/8` обратно соседям. Причина: ни у одного BGP-соседа не было outbound-фильтра — `no bgp ebgp-requires-policy` отключает защитный механизм FRR, а единственный существующий route-map (`RM-PREPEND-OUT`, только на резервном канале) фильтрации не делал (`permit 10` без `match`). Роутеры многодомны в 2 разных внешних AS без изоляции — любой маршрут в BGP-таблице реэкспортировался всем eBGP-соседям (классический непреднамеренный transit/route-leak).
## Решение
`frr.conf.j2` (общий для всех окружений, включая площадку A — та же уязвимость структурно применима и там):
- `ip prefix-list PL-ANYCAST-OUT permit <anycast_prefix>`.
- `route-map RM-OUT` (filter-only) и `route-map RM-OUT-PREPEND` (filter + prepend, рендерится только если есть соседи с `prepend: true`) — оба матчат `PL-ANYCAST-OUT`, оба заканчиваются явным `deny 20`.
- `neighbor ... route-map out` теперь навешан на **всех** соседей без исключения: `RM-OUT-PREPEND` для `prepend: true`, `RM-OUT` для остальных.
- `no bgp ebgp-requires-policy` сознательно **не убран** — иначе FRR перестанет принимать вообще все inbound-маршруты без явной inbound-policy (отдельная задача, не входит в текущий фикс).
Golden-файлы площадки A (`frr/a{1,2,3}/frr.conf`) обновлены под новый рендер — это намеренное изменение (тот же класс уязвимости актуален и для Docker-лабы), а не регресс.
## Definition of done
- `render-check.sh` (площадка A) — совпадает с обновлёнными golden-файлами.
- `render-check-cloud.sh` — фильтр применён на всех 4 узлах, prepend — только на резервных соседях, `RM-OUT`/`RM-OUT-PREPEND` синтаксически корректны.
- README и summary обновлены.
@@ -0,0 +1,37 @@
# Summary: outbound-фильтр BGP (устранение утечки 10.0.0.0/8)
**Дата:** 2026-09-10
**План:** [2026-09-10-bgp-outbound-filter-plan.md](2026-09-10-bgp-outbound-filter-plan.md)
## Инцидент и причина
В PROD (`site-cloud`) FRR реэкспортировал входящий маршрут `10.0.0.0/8` обратно eBGP-соседям. Причина — структурная: ни у одного соседа (ни в `site-a`, ни в `site-cloud`) не было outbound-фильтра. `no bgp ebgp-requires-policy` отключал защитный механизм FRR (RFC 8212), который иначе не дал бы поднять eBGP-сессию без явной policy; единственный существующий route-map (`RM-PREPEND-OUT`, только на резервном канале site-cloud) не фильтровал — `permit 10` без `match` пропускал всё. Роутеры многодомны в разные внешние AS без изоляции по префиксам → любой маршрут в локальной BGP-таблице реэкспортировался всем eBGP-соседям.
## Что сделано
`ansible/roles/frr_anycast/templates/frr.conf.j2`:
- Добавлен `ip prefix-list PL-ANYCAST-OUT seq 5 permit <anycast_prefix>`.
- Добавлены `route-map RM-OUT` (только фильтр) и `route-map RM-OUT-PREPEND` (фильтр + prepend, рендерится только при наличии соседей с `prepend: true`) — оба матчат `PL-ANYCAST-OUT`, оба с явным `deny 20` в конце.
- Каждому соседу в `bgp_neighbors` теперь назначен outbound route-map: `RM-OUT-PREPEND`, если `prepend: true`, иначе `RM-OUT` — раньше route-map вообще не навешивался на не-prepend соседей (0% фильтрации на основном канале site-cloud и на обоих соседях site-a).
- `no bgp ebgp-requires-policy` **сознательно оставлен** — включение потребовало бы также inbound-policy на каждого соседа (иначе FRR перестанет принимать вообще все входящие маршруты), это отдельная, не запрошенная сейчас задача.
**Golden-файлы площадки A обновлены** (`frr/a{1,2,3}/frr.conf`) — добавлен тот же prefix-list/route-map-блок. Это намеренное изменение: та же уязвимость структурно присутствовала и в Docker-лабе (просто менее заметна, т.к. там оба BGP-соседа логически «свои»), фикс применён единообразно везде, где используется роль.
## Результаты проверки
- `render-check.sh` (площадка A) — 6/6 OK против обновлённых golden-файлов.
- `render-check-cloud.sh` — 80/80 проверок: у всех 4 узлов ровно один outbound route-map на каждого из 4 соседей (по правильному типу), `nft -c -f` подтверждает синтаксическую валидность nftables (без изменений в этой части).
- `--syntax-check` — OK для обоих playbook'ов.
## Не входит в этот фикс (сознательно)
- Inbound-фильтрация (RFC1918/bogon-фильтр на приём) — рекомендована как defense-in-depth, но не реализована.
- Включение `bgp ebgp-requires-policy` обратно — требует отдельной проработки inbound-policy на каждого соседа.
## Изменённые файлы
- `ansible/roles/frr_anycast/templates/frr.conf.j2`.
- `frr/a1/frr.conf`, `frr/a2/frr.conf`, `frr/a3/frr.conf` (golden, Docker-лаба).
- `ansible/tests/render-check-cloud.sh` — обновлены проверки под новые имена route-map/prefix-list.
- `README.md` — пояснение про обязательный outbound-фильтр.
- `docs/2026-09-10-bgp-outbound-filter-plan.md`, `docs/2026-09-10-bgp-outbound-filter-summary.md`.
+9
View File
@@ -7,6 +7,13 @@ no ipv6 forwarding
interface dummy0 interface dummy0
ip address 10.200.200.1/32 ip address 10.200.200.1/32
! !
ip prefix-list PL-ANYCAST-OUT seq 5 permit 10.200.200.1/32
!
route-map RM-OUT permit 10
match ip address prefix-list PL-ANYCAST-OUT
!
route-map RM-OUT deny 20
!
router bgp 65001 router bgp 65001
bgp router-id 10.0.0.1 bgp router-id 10.0.0.1
no bgp ebgp-requires-policy no bgp ebgp-requires-policy
@@ -17,6 +24,8 @@ router bgp 65001
network 10.200.200.1/32 network 10.200.200.1/32
neighbor 10.0.1.2 activate neighbor 10.0.1.2 activate
neighbor 10.0.2.2 activate neighbor 10.0.2.2 activate
neighbor 10.0.1.2 route-map RM-OUT out
neighbor 10.0.2.2 route-map RM-OUT out
exit-address-family exit-address-family
! !
line vty line vty
+9
View File
@@ -7,6 +7,13 @@ no ipv6 forwarding
interface dummy0 interface dummy0
ip address 10.200.200.1/32 ip address 10.200.200.1/32
! !
ip prefix-list PL-ANYCAST-OUT seq 5 permit 10.200.200.1/32
!
route-map RM-OUT permit 10
match ip address prefix-list PL-ANYCAST-OUT
!
route-map RM-OUT deny 20
!
router bgp 65001 router bgp 65001
bgp router-id 10.0.0.2 bgp router-id 10.0.0.2
no bgp ebgp-requires-policy no bgp ebgp-requires-policy
@@ -17,6 +24,8 @@ router bgp 65001
network 10.200.200.1/32 network 10.200.200.1/32
neighbor 10.0.3.2 activate neighbor 10.0.3.2 activate
neighbor 10.0.4.2 activate neighbor 10.0.4.2 activate
neighbor 10.0.3.2 route-map RM-OUT out
neighbor 10.0.4.2 route-map RM-OUT out
exit-address-family exit-address-family
! !
line vty line vty
+9
View File
@@ -7,6 +7,13 @@ no ipv6 forwarding
interface dummy0 interface dummy0
ip address 10.200.200.1/32 ip address 10.200.200.1/32
! !
ip prefix-list PL-ANYCAST-OUT seq 5 permit 10.200.200.1/32
!
route-map RM-OUT permit 10
match ip address prefix-list PL-ANYCAST-OUT
!
route-map RM-OUT deny 20
!
router bgp 65001 router bgp 65001
bgp router-id 10.0.0.3 bgp router-id 10.0.0.3
no bgp ebgp-requires-policy no bgp ebgp-requires-policy
@@ -17,6 +24,8 @@ router bgp 65001
network 10.200.200.1/32 network 10.200.200.1/32
neighbor 10.0.5.2 activate neighbor 10.0.5.2 activate
neighbor 10.0.6.2 activate neighbor 10.0.6.2 activate
neighbor 10.0.5.2 route-map RM-OUT out
neighbor 10.0.6.2 route-map RM-OUT out
exit-address-family exit-address-family
! !
line vty line vty