mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
417 lines
13 KiB
Go
417 lines
13 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
repeaterMaxBody = 2 << 20 // 2 MiB, for both the JSON request and response preview
|
||
repeaterDefaultTimeout = 30_000
|
||
repeaterMinTimeout = 1_000
|
||
repeaterMaxTimeout = 120_000
|
||
repeaterAllowLocalEnv = "ARTEX_REPEATER_ALLOW_LOCAL"
|
||
)
|
||
|
||
var repeaterHopByHop = map[string]struct{}{
|
||
"Connection": {}, "Keep-Alive": {}, "Proxy-Connection": {}, "Transfer-Encoding": {},
|
||
"Te": {}, "Trailer": {}, "Upgrade": {}, "Content-Length": {}, "Proxy-Authorization": {},
|
||
}
|
||
|
||
type repeaterSendRequest struct {
|
||
Method *string `json:"method"`
|
||
URL string `json:"url"`
|
||
Headers map[string]string `json:"headers"`
|
||
Body string `json:"body"`
|
||
TimeoutMs int `json:"timeout_ms"`
|
||
InsecureSkipVerify bool `json:"insecure_skip_verify"`
|
||
UseRecordingProxy *bool `json:"use_recording_proxy"`
|
||
}
|
||
|
||
type repeaterSendResponse struct {
|
||
Status int `json:"status"`
|
||
StatusText string `json:"status_text"`
|
||
Proto string `json:"proto"`
|
||
Headers map[string][]string `json:"headers"`
|
||
Body string `json:"body"`
|
||
BodyTruncated bool `json:"body_truncated"`
|
||
BodyBytes int `json:"body_bytes"`
|
||
ElapsedMs int64 `json:"elapsed_ms"`
|
||
URL string `json:"url"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
func (s *Server) registerRepeater(mux *http.ServeMux) {
|
||
mux.HandleFunc("POST /api/repeater/send", s.sendRepeater)
|
||
}
|
||
|
||
func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) {
|
||
var in repeaterSendRequest
|
||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, repeaterMaxBody))
|
||
if err := dec.Decode(&in); err != nil {
|
||
writeErr(w, http.StatusBadRequest, "请求体无效")
|
||
return
|
||
}
|
||
// Reject a second JSON value (or oversized trailing data) instead of silently
|
||
// accepting bytes beyond the documented 2 MiB request limit.
|
||
var extra any
|
||
if err := dec.Decode(&extra); err != io.EOF {
|
||
writeErr(w, http.StatusBadRequest, "请求体无效")
|
||
return
|
||
}
|
||
|
||
method := http.MethodGet
|
||
if in.Method != nil {
|
||
method = strings.ToUpper(strings.TrimSpace(*in.Method))
|
||
}
|
||
if method == "" {
|
||
writeErr(w, http.StatusBadRequest, "HTTP 方法不能为空")
|
||
return
|
||
}
|
||
switch method {
|
||
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
|
||
http.MethodDelete, http.MethodHead, http.MethodOptions:
|
||
default:
|
||
writeErr(w, http.StatusBadRequest, "不支持的 HTTP 方法")
|
||
return
|
||
}
|
||
|
||
rawURL := strings.TrimSpace(in.URL)
|
||
if rawURL == "" {
|
||
writeErr(w, http.StatusBadRequest, "url 必填")
|
||
return
|
||
}
|
||
u, err := url.Parse(rawURL)
|
||
if err != nil || u.Host == "" || u.Hostname() == "" ||
|
||
!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
|
||
writeErr(w, http.StatusBadRequest, "仅支持绝对 http/https URL")
|
||
return
|
||
}
|
||
// This check is needed even when a proxy is used: the proxy must remain usable
|
||
// for normal targets, while obvious literal metadata/loopback URLs stay blocked.
|
||
if err := repeaterCheckURL(u); err != nil {
|
||
writeErr(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
timeoutMs := in.TimeoutMs
|
||
if timeoutMs <= 0 {
|
||
timeoutMs = repeaterDefaultTimeout
|
||
}
|
||
if timeoutMs < repeaterMinTimeout {
|
||
timeoutMs = repeaterMinTimeout
|
||
}
|
||
if timeoutMs > repeaterMaxTimeout {
|
||
timeoutMs = repeaterMaxTimeout
|
||
}
|
||
useRecordingProxy := true
|
||
if in.UseRecordingProxy != nil {
|
||
useRecordingProxy = *in.UseRecordingProxy
|
||
}
|
||
|
||
var body io.Reader
|
||
if in.Body != "" && method != http.MethodHead {
|
||
body = strings.NewReader(in.Body)
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutMs)*time.Millisecond)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "构造请求失败")
|
||
return
|
||
}
|
||
for key, value := range in.Headers {
|
||
name := strings.TrimSpace(key)
|
||
if name == "" {
|
||
continue
|
||
}
|
||
if _, blocked := repeaterHopByHop[http.CanonicalHeaderKey(name)]; blocked {
|
||
continue
|
||
}
|
||
if strings.EqualFold(name, "Host") {
|
||
// Host is a special Request field, but retaining it in Header too makes
|
||
// the editor semantics explicit; net/http uses req.Host on the wire.
|
||
req.Host = strings.TrimSpace(value)
|
||
req.Header.Set("Host", strings.TrimSpace(value))
|
||
continue
|
||
}
|
||
req.Header.Set(name, value)
|
||
}
|
||
|
||
client := &http.Client{
|
||
Timeout: time.Duration(timeoutMs) * time.Millisecond,
|
||
Transport: s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify),
|
||
CheckRedirect: func(next *http.Request, via []*http.Request) error {
|
||
if len(via) >= 10 {
|
||
return errors.New("重定向次数过多")
|
||
}
|
||
if err := repeaterCheckURL(next.URL); err != nil {
|
||
return err
|
||
}
|
||
if len(via) > 0 && !strings.EqualFold(next.URL.Host, via[0].URL.Host) {
|
||
return fmt.Errorf("拒绝跨主机重定向(%s → %s)", via[0].URL.Host, next.URL.Host)
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
// Each send owns its transport. Registered before Body.Close so idle
|
||
// connections are released after the response body, including on errors.
|
||
defer client.CloseIdleConnections()
|
||
|
||
started := time.Now()
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusOK, repeaterSendResponse{
|
||
Headers: map[string][]string{}, URL: u.String(),
|
||
ElapsedMs: time.Since(started).Milliseconds(), Error: repeaterRedactError(err),
|
||
})
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
out := repeaterSendResponse{
|
||
Status: resp.StatusCode, StatusText: resp.Status, Proto: resp.Proto,
|
||
Headers: resp.Header.Clone(), URL: resp.Request.URL.String(),
|
||
}
|
||
if method != http.MethodHead {
|
||
// Read one byte beyond the cap so truncation is exact. The MVP keeps the
|
||
// preview as UTF-8 text; invalid bytes are replaced instead of base64.
|
||
data, readErr := io.ReadAll(io.LimitReader(resp.Body, repeaterMaxBody+1))
|
||
if len(data) > repeaterMaxBody {
|
||
out.BodyTruncated = true
|
||
data = data[:repeaterMaxBody]
|
||
}
|
||
out.Body = repeaterBodyText(data)
|
||
out.BodyBytes = len(data)
|
||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||
out.Error = "读取响应失败: " + readErr.Error()
|
||
}
|
||
}
|
||
out.ElapsedMs = time.Since(started).Milliseconds()
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// repeaterBodyText keeps the response preview as text for the MVP. Invalid
|
||
// UTF-8 and NUL bytes are replaced so a binary response cannot inject control
|
||
// characters into the JSON/UI raw view; the original bytes are not base64
|
||
// encoded and the preview remains bounded by repeaterMaxBody.
|
||
func repeaterBodyText(data []byte) string {
|
||
text := string(bytes.ToValidUTF8(data, []byte("�")))
|
||
return strings.ReplaceAll(text, "\x00", "�")
|
||
}
|
||
|
||
func (s *Server) repeaterTransport(useRecordingProxy, insecure bool) http.RoundTripper {
|
||
var transport *http.Transport
|
||
proxied := false
|
||
if useRecordingProxy && s != nil && s.m != nil {
|
||
transport = s.httpProxyTransport(httpExec{UseRecordingProxy: true})
|
||
proxied = transport != nil
|
||
}
|
||
if transport == nil && s != nil && s.m != nil && strings.TrimSpace(s.m.GlobalProxy()) != "" {
|
||
transport = s.httpProxyTransport(httpExec{Proxy: s.m.GlobalProxy()})
|
||
proxied = transport != nil
|
||
}
|
||
if transport == nil {
|
||
if base, ok := http.DefaultTransport.(*http.Transport); ok {
|
||
transport = base.Clone()
|
||
} else {
|
||
transport = &http.Transport{}
|
||
}
|
||
// "Direct" means direct even if the process inherited HTTP_PROXY. The
|
||
// configured recording/global proxy paths are handled above explicitly.
|
||
transport.Proxy = nil
|
||
}
|
||
transport = transport.Clone()
|
||
// Keep response bytes faithful and make body_bytes match what was received;
|
||
// transparent gzip decompression would otherwise obscure both values.
|
||
transport.DisableCompression = true
|
||
// Use the request's timeout_ms budget instead of inherited phase deadlines.
|
||
transport.TLSHandshakeTimeout = 0
|
||
transport.ExpectContinueTimeout = 0
|
||
if !proxied {
|
||
// Clear a custom TLS dialer before installing the guarded dialer; when
|
||
// DialTLSContext is set, net/http bypasses DialContext entirely.
|
||
transport.DialTLSContext = nil
|
||
transport.DialTLS = nil
|
||
transport.Dial = nil
|
||
// Let the request context enforce timeout_ms for the entire dial.
|
||
transport.DialContext = (&net.Dialer{
|
||
KeepAlive: 30 * time.Second,
|
||
Control: repeaterBlockSensitiveDial,
|
||
}).DialContext
|
||
}
|
||
if insecure {
|
||
cfg := &tls.Config{InsecureSkipVerify: true} //nolint:gosec // explicit user option for pentest targets
|
||
if transport.TLSClientConfig != nil {
|
||
cfg = transport.TLSClientConfig.Clone()
|
||
cfg.InsecureSkipVerify = true
|
||
}
|
||
transport.TLSClientConfig = cfg
|
||
}
|
||
return transport
|
||
}
|
||
|
||
func repeaterAllowLocal() bool {
|
||
v := strings.TrimSpace(os.Getenv(repeaterAllowLocalEnv))
|
||
return v == "1" || strings.EqualFold(v, "true")
|
||
}
|
||
|
||
func repeaterCheckURL(u *url.URL) error {
|
||
if repeaterAllowLocal() {
|
||
return nil
|
||
}
|
||
host := strings.TrimSuffix(strings.TrimSpace(u.Hostname()), ".")
|
||
// URL.Hostname may retain an IPv6 zone (for example fe80::1%eth0); the
|
||
// zone is not part of the address classification.
|
||
if zone := strings.IndexByte(host, '%'); zone >= 0 {
|
||
host = host[:zone]
|
||
}
|
||
ip := net.ParseIP(host)
|
||
if ip == nil && repeaterLooksNumericHost(host) {
|
||
var ok bool
|
||
ip, ok = repeaterParseLegacyIPv4(host)
|
||
if !ok {
|
||
return fmt.Errorf("拒绝无法解析的数字主机地址 %q", host)
|
||
}
|
||
}
|
||
if strings.EqualFold(host, "localhost") || (ip != nil && repeaterIsBlockedDialIP(ip)) {
|
||
return fmt.Errorf("拒绝访问本机/链路本地地址(如确需访问本机服务,设置 %s=1)", repeaterAllowLocalEnv)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// repeaterLooksNumericHost identifies the legacy numeric spellings accepted by
|
||
// common proxy/resolver implementations (decimal, octal, hexadecimal and
|
||
// dotted inet_aton forms). A numeric-looking value that fails to parse is
|
||
// rejected instead of being handed to a proxy for its own interpretation.
|
||
func repeaterLooksNumericHost(host string) bool {
|
||
if host == "" || host[0] < '0' || host[0] > '9' {
|
||
return false
|
||
}
|
||
for i := 0; i < len(host); i++ {
|
||
c := host[i]
|
||
switch {
|
||
case c == '.':
|
||
case c >= '0' && c <= '9':
|
||
case c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
|
||
case c == 'x' || c == 'X':
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// repeaterParseLegacyIPv4 follows inet_aton's 1-, 2-, 3- and 4-part IPv4
|
||
// notation. Parts with a leading zero are octal and parts with 0x are hex.
|
||
func repeaterParseLegacyIPv4(host string) (net.IP, bool) {
|
||
parts := strings.Split(host, ".")
|
||
if len(parts) < 1 || len(parts) > 4 {
|
||
return nil, false
|
||
}
|
||
values := make([]uint64, len(parts))
|
||
for i, part := range parts {
|
||
if part == "" {
|
||
return nil, false
|
||
}
|
||
base := 10
|
||
digits := part
|
||
if len(digits) > 2 && digits[0] == '0' && (digits[1] == 'x' || digits[1] == 'X') {
|
||
base, digits = 16, digits[2:]
|
||
} else if len(digits) > 1 && digits[0] == '0' {
|
||
base = 8
|
||
}
|
||
if digits == "" {
|
||
return nil, false
|
||
}
|
||
value, err := strconv.ParseUint(digits, base, 32)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
values[i] = value
|
||
}
|
||
var value uint64
|
||
switch len(values) {
|
||
case 1:
|
||
value = values[0]
|
||
case 2:
|
||
if values[0] > 0xff || values[1] > 0xffffff {
|
||
return nil, false
|
||
}
|
||
value = values[0]<<24 | values[1]
|
||
case 3:
|
||
if values[0] > 0xff || values[1] > 0xff || values[2] > 0xffff {
|
||
return nil, false
|
||
}
|
||
value = values[0]<<24 | values[1]<<16 | values[2]
|
||
case 4:
|
||
for _, part := range values {
|
||
if part > 0xff {
|
||
return nil, false
|
||
}
|
||
}
|
||
value = values[0]<<24 | values[1]<<16 | values[2]<<8 | values[3]
|
||
}
|
||
if value > 0xffffffff {
|
||
return nil, false
|
||
}
|
||
return net.IPv4(byte(value>>24), byte(value>>16), byte(value>>8), byte(value)), true
|
||
}
|
||
|
||
func repeaterIsBlockedDialIP(ip net.IP) bool {
|
||
if ip == nil {
|
||
return true
|
||
}
|
||
if v4 := ip.To4(); v4 != nil {
|
||
ip = v4
|
||
}
|
||
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||
ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
|
||
}
|
||
|
||
func repeaterBlockSensitiveDial(_, address string, _ syscall.RawConn) error {
|
||
if repeaterAllowLocal() {
|
||
return nil
|
||
}
|
||
host, _, err := net.SplitHostPort(address)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ip := net.ParseIP(host)
|
||
if ip == nil {
|
||
return fmt.Errorf("无法解析目标地址 %q", host)
|
||
}
|
||
if repeaterIsBlockedDialIP(ip) {
|
||
return fmt.Errorf("拒绝访问本机/链路本地地址 %s(如确需访问本机服务,设置 %s=1)", ip, repeaterAllowLocalEnv)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func repeaterRedactError(err error) string {
|
||
var urlErr *url.Error
|
||
if errors.As(err, &urlErr) {
|
||
host := ""
|
||
if parsed, parseErr := url.Parse(urlErr.URL); parseErr == nil {
|
||
host = parsed.Host
|
||
}
|
||
if urlErr.Err != nil {
|
||
return fmt.Sprintf("%s %s: %s", urlErr.Op, host, urlErr.Err)
|
||
}
|
||
return fmt.Sprintf("%s %s: 未知错误", urlErr.Op, host)
|
||
}
|
||
return err.Error()
|
||
}
|