package api import ( "bytes" "context" "errors" "io" "log/slog" "net" "net/http" "net/netip" "net/url" "rawmark/internal/i18n" "syscall" "time" "rawmark/internal/vault" ) var ( errImageAddress = i18n.New("image_url_must_be_a_391763") errImageType = i18n.New("only_png_jpeg_gif_and_a413cf") ) var blockedImageNetworks = []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("240.0.0.0/4"), netip.MustParsePrefix("2001::/23"), netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"), netip.MustParsePrefix("3fff::/20"), } func publicImageIP(ip netip.Addr) bool { ip = ip.Unmap() if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() { return false } // Reject IPv6 translation prefixes so embedded IPv4 addresses cannot reach private networks. if ip.Is6() && !netip.MustParsePrefix("2000::/3").Contains(ip) { return false } for _, prefix := range blockedImageNetworks { if prefix.Contains(ip) { return false } } return true } func checkImageURL(u *url.URL) error { if (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil { return errImageAddress } return nil } var remoteImageClient = &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ // Ignore environment proxies so every actual connection passes destination-address validation. DialContext: (&net.Dialer{ Timeout: 5 * time.Second, ControlContext: func(_ context.Context, _, address string, _ syscall.RawConn) error { host, _, err := net.SplitHostPort(address) if err != nil { return err } ip, err := netip.ParseAddr(host) if err != nil || !publicImageIP(ip) { return errImageAddress } local, err := net.InterfaceAddrs() if err != nil { return err } for _, addr := range local { prefix, err := netip.ParsePrefix(addr.String()) if err != nil { return err } if ip.Unmap() == prefix.Addr().Unmap() { return errImageAddress } } return nil }, }).DialContext, TLSHandshakeTimeout: 5 * time.Second, IdleConnTimeout: 30 * time.Second, ForceAttemptHTTP2: true, }, CheckRedirect: func(r *http.Request, via []*http.Request) error { if len(via) >= 5 { return i18n.New("too_many_redirects_from_the_6738ba") } r.Header.Del("Referer") return checkImageURL(r.URL) }, } // readImageDownload determines type from file signatures, not the source extension or Content-Type. func readImageDownload(body io.Reader) ([]byte, string, error) { data, err := io.ReadAll(io.LimitReader(body, maxAssetBytes+1)) if err != nil { return nil, "", err } if int64(len(data)) > maxAssetBytes { return nil, "", vault.ErrTooLarge } var ext string switch http.DetectContentType(data) { case "image/png": ext = ".png" case "image/jpeg": ext = ".jpg" case "image/gif": ext = ".gif" case "image/webp": ext = ".webp" default: return nil, "", errImageType } return data, ext, nil } func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) { var body struct{ URL string } if !readJSON(w, r, &body) { return } u, err := url.Parse(body.URL) if err != nil || checkImageURL(u) != nil { writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w))) return } note := r.URL.Query().Get("note") if !vault.IsNote(note) { writeError(w, 400, message(w, "invalid_note_path_be69bd")) return } if _, err := s.vault.Stat(note); err != nil { vaultError(w, r, err) return } for attempt := 0; attempt < 2; attempt++ { if attempt > 0 { timer := time.NewTimer(time.Second) select { case <-r.Context().Done(): timer.Stop() return case <-timer.C: } } req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u.String(), nil) if err != nil { writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w))) return } res, err := remoteImageClient.Do(req) if err != nil { var timeout net.Error if attempt == 0 && errors.As(err, &timeout) && timeout.Timeout() && r.Context().Err() == nil { continue } if errors.Is(err, errImageAddress) { writeError(w, 400, i18n.RenderError(errImageAddress, responseLocale(w))) return } // url.Error includes the full source URL, potentially with temporary signatures; log only its underlying cause. var urlError *url.Error if errors.As(err, &urlError) { err = urlError.Err } slog.Error("下载图片失败", "err", err) writeError(w, 502, message(w, "could_not_download_image_b4a293")) return } if res.StatusCode < 200 || res.StatusCode >= 300 { res.Body.Close() if attempt == 0 && (res.StatusCode == 429 || res.StatusCode >= 500 && res.StatusCode < 600) { continue } writeError(w, 502, message(w, "image_source_http", res.StatusCode)) return } data, ext, err := readImageDownload(res.Body) res.Body.Close() if err != nil { var timeout net.Error if attempt == 0 && errors.As(err, &timeout) && timeout.Timeout() && r.Context().Err() == nil { continue } if errors.Is(err, vault.ErrTooLarge) { vaultError(w, r, err) } else if errors.Is(err, errImageType) { writeError(w, 422, i18n.RenderError(err, responseLocale(w))) } else { slog.Error("读取下载图片失败", "err", err) writeError(w, 502, message(w, "could_not_read_downloaded_image_4b7fdf")) } return } // External programs may move or delete the note during download; do not create orphaned resources at the old path. info, err := s.vault.Stat(note) if err != nil { vaultError(w, r, err) return } if info.IsDir() { writeError(w, 400, message(w, "the_owning_path_must_be_867b53")) return } link, err := s.vault.SaveAsset(note, "image"+ext, bytes.NewReader(data), maxAssetBytes) if err != nil { vaultError(w, r, err) return } writeJSON(w, 200, map[string]string{"link": link}) return } }