From 804fee4ffa61392f5b85011b5e3a2cb23e024fbc Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Thu, 10 Sep 2026 17:57:51 +0300 Subject: [PATCH] Fix BGP route leak: mandatory outbound prefix-filter on every neighbor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Bc3HkMiDwooSyLBLoMKNyF --- README.md | 4 +- .../roles/frr_anycast/templates/frr.conf.j2 | 16 ++++++-- ansible/tests/render-check-cloud.sh | 15 +++++--- docs/2026-09-10-bgp-outbound-filter-plan.md | 23 ++++++++++++ .../2026-09-10-bgp-outbound-filter-summary.md | 37 +++++++++++++++++++ frr/a1/frr.conf | 9 +++++ frr/a2/frr.conf | 9 +++++ frr/a3/frr.conf | 9 +++++ 8 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 docs/2026-09-10-bgp-outbound-filter-plan.md create mode 100644 docs/2026-09-10-bgp-outbound-filter-summary.md diff --git a/README.md b/README.md index e285de0..ca47a22 100644 --- a/README.md +++ b/README.md @@ -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`. +**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) — доработка под второе окружение (см. ниже). ### Структура @@ -160,6 +162,6 @@ ansible/tests/render-check.sh # регресс: площадка A ре 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). diff --git a/ansible/roles/frr_anycast/templates/frr.conf.j2 b/ansible/roles/frr_anycast/templates/frr.conf.j2 index 8b20e5f..b9b3973 100644 --- a/ansible/roles/frr_anycast/templates/frr.conf.j2 +++ b/ansible/roles/frr_anycast/templates/frr.conf.j2 @@ -4,10 +4,20 @@ hostname {{ inventory_hostname }} log stdout informational 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 %} -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 }} ! +route-map RM-OUT-PREPEND deny 20 +! {% endif %} router bgp {{ local_asn }} bgp router-id {{ router_id }} @@ -21,8 +31,8 @@ router bgp {{ local_asn }} {% for n in bgp_neighbors %} neighbor {{ n.ip }} activate {% endfor %} -{% for n in bgp_neighbors if n.prepend %} - neighbor {{ n.ip }} route-map RM-PREPEND-OUT out +{% for n in bgp_neighbors %} + neighbor {{ n.ip }} route-map {{ 'RM-OUT-PREPEND' if n.prepend else 'RM-OUT' }} out {% endfor %} exit-address-family ! diff --git a/ansible/tests/render-check-cloud.sh b/ansible/tests/render-check-cloud.sh index 1fa8883..03cfc0f 100755 --- a/ansible/tests/render-check-cloud.sh +++ b/ansible/tests/render-check-cloud.sh @@ -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 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 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 route-map on backup1 only" "$f" "neighbor $BACKUP1_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 1 - check "$n route-map on backup2 only" "$f" "neighbor $BACKUP2_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 1 - check "$n NO route-map on main1" "$f" "neighbor $MAIN1_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 0 - check "$n NO route-map on main2" "$f" "neighbor $MAIN2_NEIGHBOR_IP route-map RM-PREPEND-OUT out" 0 + check "$n filter+prepend on backup1" "$f" "neighbor $BACKUP1_NEIGHBOR_IP route-map RM-OUT-PREPEND out" 1 + check "$n filter+prepend on backup2" "$f" "neighbor $BACKUP2_NEIGHBOR_IP route-map RM-OUT-PREPEND out" 1 + check "$n filter-only on main1" "$f" "neighbor $MAIN1_NEIGHBOR_IP route-map RM-OUT out" 1 + 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" check "$n nftables masquerade" "$nft" "masquerade" 1 diff --git a/docs/2026-09-10-bgp-outbound-filter-plan.md b/docs/2026-09-10-bgp-outbound-filter-plan.md new file mode 100644 index 0000000..69cac5f --- /dev/null +++ b/docs/2026-09-10-bgp-outbound-filter-plan.md @@ -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 `. +- `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 обновлены. diff --git a/docs/2026-09-10-bgp-outbound-filter-summary.md b/docs/2026-09-10-bgp-outbound-filter-summary.md new file mode 100644 index 0000000..6aab8d8 --- /dev/null +++ b/docs/2026-09-10-bgp-outbound-filter-summary.md @@ -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 `. +- Добавлены `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`. diff --git a/frr/a1/frr.conf b/frr/a1/frr.conf index 3e58b57..5239f0a 100644 --- a/frr/a1/frr.conf +++ b/frr/a1/frr.conf @@ -7,6 +7,13 @@ no ipv6 forwarding interface dummy0 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 bgp router-id 10.0.0.1 no bgp ebgp-requires-policy @@ -17,6 +24,8 @@ router bgp 65001 network 10.200.200.1/32 neighbor 10.0.1.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 ! line vty diff --git a/frr/a2/frr.conf b/frr/a2/frr.conf index 6f4321b..bb4ffe5 100644 --- a/frr/a2/frr.conf +++ b/frr/a2/frr.conf @@ -7,6 +7,13 @@ no ipv6 forwarding interface dummy0 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 bgp router-id 10.0.0.2 no bgp ebgp-requires-policy @@ -17,6 +24,8 @@ router bgp 65001 network 10.200.200.1/32 neighbor 10.0.3.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 ! line vty diff --git a/frr/a3/frr.conf b/frr/a3/frr.conf index 83496b0..f593e47 100644 --- a/frr/a3/frr.conf +++ b/frr/a3/frr.conf @@ -7,6 +7,13 @@ no ipv6 forwarding interface dummy0 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 bgp router-id 10.0.0.3 no bgp ebgp-requires-policy @@ -17,6 +24,8 @@ router bgp 65001 network 10.200.200.1/32 neighbor 10.0.5.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 ! line vty