burterm/internal/proxy/proxy.go
2026-09-14 10:55:07 +03:00

301 lines
11 KiB
Go
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.

// Package proxy реализует форвардный HTTP(S)-прокси с перехватом
// трафика (MITM), аналог Burp Proxy / mitmproxy. Браузер настраивается
// ходить через Server.Addr; для HTTP запросы читаются напрямую, для
// HTTPS используется CONNECT-туннель с подменой TLS-сертификата на
// лету (см. CA.LeafFor) — отсюда необходимость один раз импортировать
// ca.pem в доверенные корни браузера/ОС.
//
// Известные упрощения первой версии (не блокирующие базовый сценарий
// перехвата и правки запроса, но стоящие в очереди на доработку):
// - hop-by-hop заголовки (Connection, Proxy-Connection и т.п.) не
// вырезаются перед проксированием;
// - WebSocket-апгрейды не поддерживаются;
// - HTTP/2 на туннеле не форсируется (сервер объявляет только h1
// в ALPN, так что браузер сам откатится на HTTP/1.1 для
// туннелированных доменов).
package proxy
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"sync"
"time"
"github.com/r3g1tpr0cs/burterm/internal/sitemap"
)
// maxResponseCapture — сколько байт тела ответа сохраняется в
// Entry.ResponseBody для полнотекстового поиска в Project. Больше, чем
// в Intruder (4КБ) — трафик через Proxy обычно не такой массовый, можно
// позволить себе больше на хранение.
const maxResponseCapture = 8 * 1024
// Server — форвардный прокси. Каждый прошедший через него запрос
// попадает в Store, откуда его можно подхватить в Request Builder.
type Server struct {
Addr string
CA *CA
Store *Store
// scope, если задан, ограничивает то, что реально попадает в Store —
// сам прокси всё равно обслуживает ЛЮБОЙ трафик (клиент получает
// ответ независимо от scope), просто то, что вне scope, не засоряет
// историю. nil (значение по умолчанию) — логировать всё, как раньше.
// Под мьютексом: SetScope дёргается из горутины TUI, а capture/
// captureErr читают его из совсем других горутин — по одной на
// соединение.
scopeMu sync.RWMutex
scope *sitemap.Scope
client *http.Client
}
// SetScope задаёт (или снимает, если nil) scope, ограничивающий то, что
// попадает в Store. Потокобезопасно относительно capture/captureErr.
// Передаётся независимая копия сверху (см. TUI-слой) — Server не должен
// делить один и тот же *sitemap.Scope с чем-то ещё мутируемым конкурентно,
// иначе Matches() читал бы срез правил без синхронизации с их же записью.
func (s *Server) SetScope(scope *sitemap.Scope) {
s.scopeMu.Lock()
s.scope = scope
s.scopeMu.Unlock()
}
// inScope — под nil-scope пропускает всё (обратная совместимость с
// поведением до появления этой фичи).
func (s *Server) inScope(url string) bool {
s.scopeMu.RLock()
scope := s.scope
s.scopeMu.RUnlock()
if scope == nil {
return true
}
return scope.Matches(url)
}
// New создаёт Server, готовый к ListenAndServe.
func New(addr string, ca *CA, store *Store) *Server {
return &Server{
Addr: addr,
CA: ca,
Store: store,
client: &http.Client{
// Собственный Transport — чтобы не поймать петлю через
// переменные окружения HTTP_PROXY/HTTPS_PROXY.
Transport: &http.Transport{Proxy: nil},
// Редиректы не разворачиваем автоматически — пользователь
// должен увидеть 30x как отдельную перехваченную транзакцию,
// а не только её конечную точку.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// ListenAndServe запускает прокси и блокируется, пока ctx не отменён
// или не произойдёт фатальная ошибка сервера.
func (s *Server) ListenAndServe(ctx context.Context) error {
srv := &http.Server{
Addr: s.Addr,
Handler: http.HandlerFunc(s.serve),
}
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
case err := <-errCh:
return err
}
}
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
s.serveConnect(w, r)
return
}
s.servePlain(w, r)
}
// servePlain обрабатывает обычный нешифрованный прокси-запрос — браузер
// шлёт его с абсолютным URI в request line (GET http://host/path HTTP/1.1).
func (s *Server) servePlain(w http.ResponseWriter, r *http.Request) {
// DumpRequest вычитывает r.Body и сам восстанавливает его новым
// io.ReadCloser с теми же байтами — r остаётся пригоден для
// дальнейшей пересылки.
dump, _ := httputil.DumpRequest(r, true)
outURL := *r.URL
if outURL.Scheme == "" {
outURL.Scheme = "http"
}
if outURL.Host == "" {
outURL.Host = r.Host
}
outReq, err := http.NewRequest(r.Method, outURL.String(), r.Body)
if err != nil {
s.captureErr(r, err)
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
outReq.Header = r.Header.Clone()
resp, err := s.client.Do(outReq)
if err != nil {
s.captureErr(r, err)
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Читаем тело целиком — не обрезаем то, что реально уходит клиенту.
// Ошибку чтения намеренно не считаем фатальной: то, что успели
// прочитать, всё равно форвардим и сохраняем как есть.
fullBody, _ := io.ReadAll(resp.Body)
s.capture(r, dump, resp, fullBody)
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
w.Write(fullBody)
}
// serveConnect обрабатывает CONNECT (HTTPS-туннель): хайджекает
// соединение, отвечает "200 Connection Established", затем сам
// выступает TLS-сервером с сертификатом, подписанным локальным CA
// (GetCertificate генерирует лист по SNI на лету), и внутри
// расшифрованного канала читает и пересылает HTTP-запросы как обычный
// клиент — это и есть MITM-перехват.
func (s *Server) serveConnect(w http.ResponseWriter, r *http.Request) {
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "хайджек соединения не поддерживается", http.StatusInternalServerError)
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
return
}
defer clientConn.Close()
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host // Host уже без порта, если SplitHostPort не смог разобрать
}
if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
return
}
tlsConn := tls.Server(clientConn, &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
name := hello.ServerName
if name == "" {
name = host
}
return s.CA.LeafFor(name)
},
})
defer tlsConn.Close()
if err := tlsConn.Handshake(); err != nil {
// Обычно значит, что браузер ещё не доверяет нашему CA —
// это ожидаемо до первого импорта ca.pem, молча выходим.
return
}
br := bufio.NewReader(tlsConn)
for {
req, err := http.ReadRequest(br)
if err != nil {
return
}
// http.ReadRequest для relative-form request line оставляет
// req.URL.Scheme/Host пустыми — достраиваем их из адреса
// CONNECT-туннеля, иначе пересылать запрос некуда.
req.URL.Scheme = "https"
req.URL.Host = r.Host
req.RequestURI = ""
dump, _ := httputil.DumpRequest(req, true)
resp, err := s.client.Do(req)
if err != nil {
s.captureErr(req, err)
return
}
// resp.Write() ниже сам сериализует тело из resp.Body — а нам
// то же тело нужно ещё и для capture(). Тело можно прочитать
// только один раз, поэтому читаем целиком сами и подменяем
// resp.Body свежим io.NopCloser с теми же байтами, прежде чем
// отдать resp дальше — тот же приём, что httputil.DumpRequest
// делает для тела запроса.
fullBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(fullBody))
s.capture(req, dump, resp, fullBody)
writeErr := resp.Write(tlsConn)
if writeErr != nil {
return
}
}
}
func (s *Server) capture(r *http.Request, raw []byte, resp *http.Response, fullBody []byte) {
url := r.URL.String()
if !s.inScope(url) {
return
}
captured := fullBody
if len(captured) > maxResponseCapture {
captured = captured[:maxResponseCapture]
}
s.Store.Add(Entry{
Timestamp: time.Now(),
Method: r.Method,
URL: url,
Raw: string(raw),
StatusCode: resp.StatusCode,
Length: int64(len(fullBody)), // фактически прочитанные байты, не ненадёжный resp.ContentLength (часто -1 при chunked)
Headers: resp.Header,
ResponseBody: string(captured),
})
}
func (s *Server) captureErr(r *http.Request, err error) {
url := ""
if r.URL != nil {
url = r.URL.String()
}
if url != "" && !s.inScope(url) {
return
}
s.Store.Add(Entry{
Timestamp: time.Now(),
Method: r.Method,
URL: url,
Err: fmt.Errorf("проксирование не удалось: %w", err),
})
}