Безопасность: fail-closed auth (H1), доверенный прокси для IP (H2), CSRF на /order (M2)

По результатам security-аудита (docs, run-1). Три верифицированные находки.

H1 (HIGH) — fail-open аутентификация. loadUsers() при ошибке чтения/парсинга
users.json молча возвращал пустой стор → authOpen() считал это первичной
настройкой → после удаления first-run файла весь /admin отдавался без входа.
Теперь: постоянный маркер configured (пишется при создании первого админа и
бэкфиллится при загрузке валидного стора); authOpen() открыт только на реально
свежей системе; при пустом/битом сторе на настроенной системе — 503 и блокировка
до восстановления файла (authLockedOut). Тест TestAuthFailClosed (4 сценария).

H2 (HIGH) — интернет-слой доверял клиентскому X-Forwarded-For/X-Real-IP, что
позволяло обойти лимит входа по IP и травить аудит. Теперь заголовкам доверяем
только от доверенного прокси (по умолчанию loopback; расширяется
BJ_TRUSTED_PROXIES), из XFF берём правый элемент. Исправлено и в Go-модуле
(modules/lk/security.go), и в PHP-ретрансляторе. Тесты TestClientIPTrust (Go) и
прогон логики PHP.

M2 (MEDIUM) — POST /order (поручение на списание бумаг) не проверял CSRF, в
отличие от /login и /impersonate. Добавлен csrfOK (форма уже несла токен).
Тест TestOrderCSRF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 21:25:35 +03:00
parent 9273fc492c
commit d975957335
7 changed files with 297 additions and 21 deletions
+41
View File
@@ -0,0 +1,41 @@
package lk
import (
"net/http"
"testing"
)
// H2: X-Forwarded-For/X-Real-IP от НЕдоверенного источника не должны подменять IP.
func TestClientIPTrust(t *testing.T) {
req := func(remote string, hdr map[string]string) *http.Request {
r, _ := http.NewRequest("POST", "/login", nil)
r.RemoteAddr = remote
for k, v := range hdr {
r.Header.Set(k, v)
}
return r
}
// прямой клиент из интернета подставляет заголовки — игнорируем
if got := clientIP(req("203.0.113.7:5000", map[string]string{"X-Real-IP": "1.2.3.4", "X-Forwarded-For": "9.9.9.9"})); got != "203.0.113.7" {
t.Errorf("недоверенный источник: ожидали 203.0.113.7, получили %s (спуфинг прошёл)", got)
}
// доверенный прокси (loopback) сообщает реальный адрес — доверяем X-Real-IP
if got := clientIP(req("127.0.0.1:5000", map[string]string{"X-Real-IP": "1.2.3.4"})); got != "1.2.3.4" {
t.Errorf("loopback X-Real-IP: ожидали 1.2.3.4, получили %s", got)
}
// доверенный прокси, только XFF: берём ПРАВЫЙ элемент (левые подставил клиент)
if got := clientIP(req("127.0.0.1:5000", map[string]string{"X-Forwarded-For": "9.9.9.9, 5.6.7.8"})); got != "5.6.7.8" {
t.Errorf("loopback XFF: ожидали правый 5.6.7.8, получили %s", got)
}
// без заголовков — адрес соединения
if got := clientIP(req("198.51.100.2:443", nil)); got != "198.51.100.2" {
t.Errorf("без заголовков: ожидали 198.51.100.2, получили %s", got)
}
// настраиваемый доверенный прокси
old := trustedProxies
trustedProxies = loadTrustedProxies("10.0.0.0/8")
defer func() { trustedProxies = old }()
if got := clientIP(req("10.1.2.3:5000", map[string]string{"X-Real-IP": "1.2.3.4"})); got != "1.2.3.4" {
t.Errorf("доверенный CIDR: ожидали 1.2.3.4, получили %s", got)
}
}
+8 -1
View File
@@ -1082,7 +1082,14 @@ func (s *Server) buildHandler() http.Handler {
"PickISIN": r.URL.Query().Get("isin"), "Today": time.Now().Format("02.01.2006")})
return
}
_ = r.ParseForm()
// Изменяющее действие (поручение на списание бумаг) — только по своей форме
// с CSRF-токеном, как /login и /impersonate. Иначе чужая страница могла бы
// отправить поручение от имени вошедшего клиента.
if !csrfOK(r) {
setFlashCookie(w, r, "Форма устарела, откройте поручение заново.")
http.Redirect(w, r, "/order", http.StatusSeeOther)
return
}
var secs []map[string]any
for _, isin := range r.Form["isin"] {
q := strings.TrimSpace(r.FormValue("qty_" + isin))
+23
View File
@@ -614,3 +614,26 @@ func TestChartAndProfile(t *testing.T) {
t.Error("POST /documents всё ещё принимает заказ документа")
}
}
// M2: POST /order без валидного CSRF-токена не должен уходить в ядро.
func TestOrderCSRF(t *testing.T) {
var transferCalled bool
bj := fakeBJ(t)
// добавим перехват transfer поверх fakeBJ нельзя (mux фиксирован) — проверяем по редиректу.
s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key", Title: "Кабинет"})
if err != nil {
t.Fatal(err)
}
h := s.Handler()
// без csrf-куки и поля — ожидаем редирект на /order, поручение не подаётся
form := url.Values{"isin": {"RU0009029540"}, "qty_RU0009029540": {"10"}, "receiving_inn": {"7700000000"}}
req := httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/order" {
t.Errorf("POST /order без CSRF: ожидали редирект на /order, got %d %s", rec.Code, rec.Header().Get("Location"))
}
_ = transferCalled
}
+36 -4
View File
@@ -97,14 +97,46 @@ function isLocal(): bool
return $h === 'localhost' || $h === '127.0.0.1' || $h === '::1';
}
// peerTrusted — непосредственный источник запроса (REMOTE_ADDR) — доверенный прокси:
// loopback или адрес/CIDR из переменной окружения BJ_TRUSTED_PROXIES (через запятую).
function peerTrusted(string $remote): bool
{
if ($remote === '') return false;
if ($remote === '127.0.0.1' || $remote === '::1' || str_starts_with($remote, '127.')) return true;
$list = getenv('BJ_TRUSTED_PROXIES') ?: '';
foreach (explode(',', $list) as $cidr) {
$cidr = trim($cidr);
if ($cidr === '') continue;
if (!str_contains($cidr, '/')) { if ($remote === $cidr) return true; continue; }
[$net, $bits] = explode('/', $cidr, 2);
$ipb = @inet_pton($remote); $netb = @inet_pton($net);
if ($ipb === false || $netb === false || strlen($ipb) !== strlen($netb)) continue;
$bytes = intdiv((int)$bits, 8); $rem = (int)$bits % 8;
if (($bytes > 0 && substr($ipb, 0, $bytes) !== substr($netb, 0, $bytes))) continue;
if ($rem === 0) return true;
$mask = chr((0xff << (8 - $rem)) & 0xff);
if ((ord($ipb[$bytes]) & ord($mask)) === (ord($netb[$bytes]) & ord($mask))) return true;
}
return false;
}
// clientIP — адрес посетителя. X-Real-IP / X-Forwarded-For подставляет клиент, поэтому
// доверяем им ТОЛЬКО от доверенного прокси; иначе берём адрес соединения. Иначе
// злоумышленник подделал бы свой IP и обошёл лимит входа и журнал аудита в ядре.
function clientIP(): string
{
foreach (['HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'] as $k) {
if (!empty($_SERVER[$k])) {
return trim(explode(',', $_SERVER[$k])[0]);
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
if (peerTrusted($remote)) {
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
return trim($_SERVER['HTTP_X_REAL_IP']);
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// правый элемент добавил наш доверенный прокси; левые может подставить клиент
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($parts[count($parts) - 1]);
}
}
return $_SERVER['REMOTE_ADDR'] ?? '';
return $remote;
}
function keyOK(string $key): bool
+61 -8
View File
@@ -11,6 +11,7 @@ import (
"log"
"net"
"net/http"
"os"
"strings"
)
@@ -56,17 +57,69 @@ func secureCookies(r *http.Request) bool {
return httpsRequest(r) || !localRequest(r)
}
// clientIP — адрес посетителя кабинета (за nginx — из X-Real-IP / X-Forwarded-For).
// trustedProxies — сети, которым разрешено сообщать адрес клиента заголовками
// X-Real-IP / X-Forwarded-For. По умолчанию — только loopback (nginx на том же
// хосте). Расширяется списком CIDR/адресов в BJ_TRUSTED_PROXIES (через запятую).
var trustedProxies = loadTrustedProxies(os.Getenv("BJ_TRUSTED_PROXIES"))
func loadTrustedProxies(s string) []*net.IPNet {
var out []*net.IPNet
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if !strings.Contains(part, "/") {
if strings.Contains(part, ":") {
part += "/128"
} else {
part += "/32"
}
}
if _, n, err := net.ParseCIDR(part); err == nil {
out = append(out, n)
}
}
return out
}
// peerTrusted — непосредственный источник запроса (адрес соединения) — доверенный
// прокси: loopback или явно указанный в BJ_TRUSTED_PROXIES.
func peerTrusted(host string) bool {
ip := net.ParseIP(host)
if ip == nil {
return false
}
if ip.IsLoopback() {
return true
}
for _, n := range trustedProxies {
if n.Contains(ip) {
return true
}
}
return false
}
// clientIP — адрес посетителя кабинета. Заголовки X-Real-IP / X-Forwarded-For
// подставляет клиент, поэтому доверяем им ТОЛЬКО когда запрос пришёл от доверенного
// прокси (по умолчанию nginx на том же хосте). Иначе адрес берём из соединения —
// иначе злоумышленник подделал бы свой IP и обошёл лимит входа и журнал аудита.
func clientIP(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
return v
}
if v := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); v != "" {
return strings.TrimSpace(strings.Split(v, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
host = r.RemoteAddr
}
if peerTrusted(host) {
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
return v
}
if v := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); v != "" {
// самый правый элемент добавляет наш доверенный прокси (его peer);
// левые может подставить клиент, поэтому берём правый.
parts := strings.Split(v, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
}
return host
}