Files
admin/cms-server-port-leak-fix.md
vitya 5d7a14c7d4 docs(.wiki,.tasks): close cms-port-leak-fix — URL Rewrite serverVariables на host IIS
Port :8089/:4443 утечка в admin URLs закрыта. URL Rewrite 2.1 + apphost
allowedServerVariables (HTTPS/SERVER_PORT/SERVER_PORT_SECURE) + Web.config rule
на X-Forwarded-Proto=https → set SERVER_PORT=443/SERVER_PORT_SECURE=1/HTTPS=on
ДО того как ASP.NET читает их в Url.SiteRoot(). Лечит все 11 cms (общий site
snolla). Path B (traefik http entrypoint :80→:8090) abandoned — Docker Desktop
WSL2 NAT quirk на host.docker.internal:80 возвращает 17-byte 301 plain text
независимо от traefik internals. Quirk не reproducible на Linux Docker (synology).

Side regression: pre-existing customErrors mode="off" lowercase в Web.config
пробудился после ASP.NET full-reload (мой rewrite-block edit) — fixed (Off).

Outside scope, open: emspb.snolla.com /admin/assets/<guid>/getList → 500 NullRef
в AssetsJsonViewModelBuilder.cs:22 (model null от GetFolderByPath). User
подтвердил «только этот site». Зафиксировано в snapshot open issue #8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:43:03 +03:00

175 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: CMS port-leak fix — URL Rewrite serverVariables на host IIS
type: concept
tags: [iis, url-rewrite, traefik, x-forwarded, asp-net, gotcha]
sources: [../sources/iis-host-migration-2026-05-19.md]
updated: 2026-05-19
---
# CMS port-leak fix
Решение для open issue #1 из [[recovery-architecture-snapshot]] (раньше «X-Forwarded headers не настроены, :4443 leak») и админ-utечки `:8089` обнаруженной 2026-05-19 вечером после attempt 2 host-IIS миграции. Документирует **root cause**, **почему VM работала**, **почему обходной path-B на Windows Docker Desktop невозможен**, и **что в итоге применено**.
Связано: [[traefik-on-windows-docker-desktop]] Pitfall 5, [[iis-host-migration-2026-05-19]] Phase 10, [[docker-host-loopback-detect]].
## Симптом
Admin URLs формата (после attempt 2 миграции на host-IIS:8089):
- `https://emspb.snolla.com:8089/admin/assets/<guid>/getList?path=`
- `https://emspb.snolla.com:8089/admin/themes/getImageSizes/`
- `https://emspb.snolla.com:8089/admin/templates/editors.tmpl.html?v=2.006`
Browser HSTS upgrade'ит `http://...:8089``https://...:8089` → TCP open, TLS handshake fails (8089 = plain HTTP) → admin SPA ломается.
Аналогично, ранее (issue #1) — CMS делал HTTP→HTTPS redirect с `:4443` (traefik external port).
## Root cause
`MoreThenCms.Admin\Mis\Web\Mvc\UrlHelpers\UrlHelpers.cs:14-37` `Url.SiteRoot()`:
```csharp
var port = context.Request.ServerVariables["SERVER_PORT"];
if (usePort) {
if (port == null || port == "80" || port == "443") port = "";
else port = ":" + port;
}
var protocol = context.Request.ServerVariables["SERVER_PORT_SECURE"];
if (protocol == null || protocol == "0") protocol = "http://"; else protocol = "https://";
var sOut = protocol + context.Request.ServerVariables["SERVER_NAME"] + port + appPath;
```
Читает **socket-level** server variables. На IIS site `snolla` binding `*:8089` HTTP → `SERVER_PORT=8089`, `SERVER_PORT_SECURE=0` → формирует `http://emspb.snolla.com:8089` → рендерится в Razor:
```cshtml
@* C:\sites\snolla\Views\Shared\_Layout.cshtml:229 *@
mis.siteRoot = '@Url.SiteRoot().Replace("http:", "https:")' + '/admin';
@* и _LogInLayout.cshtml:156 *@
mis.siteRoot = '@Url.SiteRoot()';
```
Замена `http:→https:` в Layout была *прошлым* частичным patch'ем — не убирает порт. Десятки .cshtml в admin также используют `Url.SiteRoot()` для inline `<style background: url(... + "/images/loading2.gif")>` — все utекают `:8089`.
## Почему VM работала (без всяких rewrite)
SSH probe VM (parallel running snolla-recovery): `appcmd list site /xml``MoreThenCms.Web bindings="http/*:80:"`. **`SERVER_PORT=80`** → `port` пустеет (whitelist `{80,443}`), `mis.siteRoot = 'http://emspb.snolla.com'` (no port, http scheme). Browser HSTS upgrade'ит до https и попадает обратно через router-NAT-traefik-chain в backend. Работает.
VM `applicationHost.config` проверен — **никаких** rewrite rules, никакого URL Rewrite/ARR модуля. Production magic = просто IIS бинд на `:80`.
## Почему path B (host IIS :80 + traefik backend :80) НЕ работает на Windows Docker Desktop
Pre-flight 2026-05-19 вечер:
```
docker exec traefik wget -qS http://host.docker.internal:80/admin/account/login
→ HTTP/1.1 301 Moved Permanently
Content-Length: 17
Content-Type: text/plain
Location: https://emspb.snolla.com/admin/account/login
(НЕТ Server: header — не IIS, не traefik)
```
`Get-NetTCPConnection -LocalPort 80 -State Listen` → только `PID=4 NAME=System` (http.sys/IIS). Никакого другого listener'а. Direct `Invoke-WebRequest http://127.0.0.1/admin/...` с правильным Host header возвращает 200 OK с `Server: Microsoft-IIS/10.0`.
Но через **`host.docker.internal:80`**, **`gateway.docker.internal:80`** (192.168.65.1), **`192.168.1.143:80`** (LAN IP) — все три возвращают тот же 17-byte 301 plain text. То есть **Docker Desktop's WSL2 NAT proxy на Windows перехватывает container-→host:80 traffic и подменяет ответ HTTP→HTTPS redirect'ом**.
Подтверждение что **не** traefik: после смены traefik http entrypoint `:80 → :8090` (`docker-compose.yml ports: 8000:8090`, `traefik.yml http.address: ":8090"`) и `docker compose up -d --force-recreate``netstat -tlnp` в контейнере показывает `:8090 LISTEN`, **нет** `:80`. И всё равно `host.docker.internal:80` возвращает тот же 17-byte 301.
**Этого quirk'а нет на Linux Docker** (synology) — там `host.docker.internal` либо не существует, либо ведёт себя стандартно. Поэтому VM-стек работал, host-Windows-Docker — нет.
## Применённое решение (path C)
URL Rewrite 2.1 + `<serverVariables>` rule на host IIS — переписывает `SERVER_PORT/SERVER_PORT_SECURE/HTTPS` ДО того как ASP.NET их читает.
### Шаги
1. **MSI install URL Rewrite 2.1** (elevated):
```powershell
$msi = "$env:TEMP\rewrite_amd64_en-US.msi"
Invoke-WebRequest 'https://download.microsoft.com/download/1/2/8/128E2E22-C1B9-44A4-BE2A-5859ED1D4592/rewrite_amd64_en-US.msi' -OutFile $msi
Start-Process msiexec -ArgumentList "/i `"$msi`" /quiet /norestart" -Verb RunAs -Wait
```
Verify: `Get-Item C:\Windows\System32\inetsrv\rewrite.dll` (FileVersion 7.1.1993.2351).
2. **applicationHost.config — `<allowedServerVariables>`** (elevated, через appcmd):
```powershell
$appcmd = 'C:\Windows\System32\inetsrv\appcmd.exe'
foreach ($n in 'HTTPS', 'SERVER_PORT', 'SERVER_PORT_SECURE') {
& $appcmd set config -section:system.webServer/rewrite/allowedServerVariables "/+[name='$n']" /commit:apphost
}
```
**Built-in IIS server variables** (HTTPS/SERVER_PORT/*) MUST be в apphost-allowedServerVariables, **не** site Web.config. Иначе HTTP 500 "URL Rewrite Module Error" без body.
3. **Site `C:\sites\snolla\Web.config`** (UTF-8 BOM!) — добавить в `<system.webServer>`:
```xml
<rewrite>
<rules>
<rule name="ForwardedProto-HTTPS" stopProcessing="false">
<match url=".*" />
<conditions>
<add input="{HTTP_X_FORWARDED_PROTO}" pattern="^https$" />
</conditions>
<serverVariables>
<set name="HTTPS" value="on" />
<set name="SERVER_PORT" value="443" />
<set name="SERVER_PORT_SECURE" value="1" />
</serverVariables>
<action type="None" />
</rule>
</rules>
</rewrite>
```
`<action type="None"/>` — rule не редиректит/переписывает URL, только сидит как side-effect ставящая server vars. `stopProcessing="false"` чтобы другие rules могли продолжить (на случай если добавим).
4. **Recycle** — IIS auto-recycle при изменении Web.config (~3 сек).
5. **Verify**:
```bash
docker exec traefik sh -c "wget -qS --no-check-certificate -O- \
--header='Host: emspb.snolla.com' \
https://127.0.0.1:443/admin/account/login 2>&1 | grep siteRoot"
# Expected: mis.siteRoot = 'https://emspb.snolla.com'; (БЕЗ :8089)
```
Traefik 2.x по-default шлёт `X-Forwarded-Proto: https` для requests через https entrypoint — наш `HTTP_X_FORWARDED_PROTO` condition fires автоматически.
### Atomic revert
```powershell
Copy-Item C:\sites\snolla\Web.config.bak-pre-portleak-2026-05-19 C:\sites\snolla\Web.config -Force
# ~3 сек на app pool reload
# apphost можно оставить (allowedServerVariables inert без rule)
# MSI URL Rewrite можно оставить (модуль без rule = no behavior)
```
## Gotchas
### `customErrors mode="off"` lowercase — fatal regression
`C:\sites\snolla\Web.config:43` имел `<customErrors mode="off" />` (lowercase). ASP.NET до моего edit'а **lazily** валидировал это — работало. После моего Web.config edit'а (добавил `<rewrite>` block) → ASP.NET full-reload → enum parse fail:
```
Значение свойства 'mode' не может быть проанализировано.
Ошибка: Значение перечисления должно быть одним из следующих: RemoteOnly, On, Off.
```
YSOD config error блокирует **весь** site. Fix: `mode="Off"` (capital O). Урок: при любом Web.config edit'е сначала grep на enum-style attributes на корректный casing.
### `[xml]` save в PowerShell flattens applicationHost.config
`$cfg.OuterXml` сбрасывает все пробелы/переносы → файл 937 строк превращается в 1 строку. IIS читает (whitespace для XML parser не важен), но файл нечитаем для людей. Backup и `appcmd set config /+...` — единственно правильный способ редактировать apphost programmatically.
### Site-level `<allowedServerVariables>` для built-in vars
Site `Web.config` MOJET содержать `<rewrite><allowedServerVariables>` ТОЛЬКО для custom variables (HTTP_X_*, etc). Для built-in IIS vars (HTTPS, SERVER_PORT, SERVER_PORT_SECURE) — apphost OR HTTP 500. Не дублировать в site-level — конфликт.
## Сделано на host'е (2026-05-19 вечер)
- `C:\Windows\System32\inetsrv\config\applicationHost.config` — `<rewrite>/<allowedServerVariables>` += HTTPS, SERVER_PORT, SERVER_PORT_SECURE. Backup `.bak-pre-portleak-2026-05-19`.
- `C:\sites\snolla\Web.config` — `<rewrite>/<rules>` += rule "ForwardedProto-HTTPS"; `customErrors mode="off"` → `"Off"`. Backup `.bak-pre-portleak-2026-05-19`.
- URL Rewrite 2.1 MSI installed.
- Затрагивает **все 11 cms hosts** (общий site `snolla`): emspb, labtools, labtoolspro, pilorama98, tandemmebel, kupimknigi, maljarka, sestech, isc-artmaterials, rimiz, plus snolla.com sub-domains.
## Известное ограничение (НЕ закрыто этим fix'ом)
`/admin/assets/<guid>/getList?path=` на `emspb.snolla.com` admin → 500 NullReferenceException в `AssetsJsonViewModelBuilder.cs:22` (`model.ParentPath` на null). User подтвердил «только этот site». `_assetsFoldersService.GetFolderByPath(ctx, ownerId, '')` возвращает null. Скорее всего связано с пустыми settings `<fileStorageClients><add name="assets">` (basePath/siteUrl/sitePath = ""). **Отдельный bug**, не в scope этого fix'а — отдельная task если будет приоритет.