476 lines
16 KiB
Go
476 lines
16 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
// Package localio owns safe local artifact publication shared by product
|
|
// shortcuts. Remote names and URLs are always treated as untrusted input.
|
|
package localio
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
pathpkg "path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
downloadTimeout = 10 * time.Minute
|
|
maxDownloadBytes = int64(512 << 20)
|
|
)
|
|
|
|
type downloadTempFile interface {
|
|
io.Writer
|
|
Sync() error
|
|
Close() error
|
|
}
|
|
|
|
var (
|
|
createDownloadTemp = createDownloadTempInRoot
|
|
lookupDownloadIPs = net.DefaultResolver.LookupIPAddr
|
|
dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
|
localGetwd = os.Getwd
|
|
localAbs = filepath.Abs
|
|
localEvalSymlinks = filepath.EvalSymlinks
|
|
openDownloadRoot = os.OpenRoot
|
|
openDownloadParent = func(root *os.Root, name string) (*os.Root, error) { return root.OpenRoot(name) }
|
|
downloadRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }
|
|
downloadRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }
|
|
downloadRootMkdir = func(root *os.Root, name string, mode os.FileMode) error { return root.Mkdir(name, mode) }
|
|
downloadRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
|
|
downloadRootRemove = func(root *os.Root, name string) error { return root.Remove(name) }
|
|
)
|
|
|
|
var downloadTempCounter atomic.Uint64
|
|
|
|
// DownloadOptions controls safe, atomic publication beneath BaseDir.
|
|
type DownloadOptions struct {
|
|
BaseDir string
|
|
Output string
|
|
PreferredName string
|
|
Headers map[string]string
|
|
}
|
|
|
|
// DownloadResult describes the published local artifact.
|
|
type DownloadResult struct {
|
|
AbsolutePath string
|
|
RelativePath string
|
|
SizeBytes int64
|
|
}
|
|
|
|
// Download validates a platform-owned HTTPS URL, resolves a workspace-relative
|
|
// output path without following symlink escapes, streams into a sibling temp
|
|
// file, fsyncs it, and atomically publishes the completed file.
|
|
func Download(ctx context.Context, rawURL string, opts DownloadOptions) (DownloadResult, error) {
|
|
return downloadWithClient(ctx, rawURL, opts, secureHTTPClient())
|
|
}
|
|
|
|
func downloadWithClient(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client) (DownloadResult, error) {
|
|
return downloadWithClientLimit(ctx, rawURL, opts, client, maxDownloadBytes)
|
|
}
|
|
|
|
func downloadWithClientLimit(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client, maxBytes int64) (DownloadResult, error) {
|
|
parsed, err := ValidateDownloadURL(rawURL)
|
|
if err != nil {
|
|
return DownloadResult{}, err
|
|
}
|
|
target, err := openDownloadTarget(opts.BaseDir, opts.Output, parsed.String(), opts.PreferredName)
|
|
if err != nil {
|
|
return DownloadResult{}, err
|
|
}
|
|
defer target.close()
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) // URL was fully validated above
|
|
for key, value := range opts.Headers {
|
|
if strings.TrimSpace(key) != "" {
|
|
req.Header.Set(key, value)
|
|
}
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return DownloadResult{}, fmt.Errorf("下载资源失败: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return DownloadResult{}, fmt.Errorf("下载资源失败: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
if resp.ContentLength > maxBytes {
|
|
return DownloadResult{}, fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 响应大小 %d 超过上限 %d 字节", resp.ContentLength, maxBytes)
|
|
}
|
|
if err := target.verifyParent(); err != nil {
|
|
return DownloadResult{}, err
|
|
}
|
|
|
|
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
|
|
if err != nil {
|
|
return DownloadResult{}, fmt.Errorf("创建下载临时文件失败: %w", err)
|
|
}
|
|
cleanup := func() {
|
|
_ = tmp.Close()
|
|
_ = target.parentRoot.Remove(tmpName)
|
|
}
|
|
size, copyErr := io.Copy(tmp, io.LimitReader(resp.Body, maxBytes+1))
|
|
if copyErr == nil && size > maxBytes {
|
|
copyErr = fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 下载内容超过上限 %d 字节", maxBytes)
|
|
}
|
|
if copyErr == nil {
|
|
copyErr = tmp.Sync()
|
|
}
|
|
if closeErr := tmp.Close(); copyErr == nil {
|
|
copyErr = closeErr
|
|
}
|
|
if copyErr != nil {
|
|
cleanup()
|
|
return DownloadResult{}, fmt.Errorf("写入下载临时文件失败: %w", copyErr)
|
|
}
|
|
if err := target.verifyParent(); err != nil {
|
|
cleanup()
|
|
return DownloadResult{}, err
|
|
}
|
|
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
|
|
cleanup()
|
|
return DownloadResult{}, err
|
|
}
|
|
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: size}, nil
|
|
}
|
|
|
|
// ValidateOutput rejects absolute paths and portable `..` escapes.
|
|
func ValidateOutput(output string) error {
|
|
output = strings.TrimSpace(output)
|
|
if output == "" {
|
|
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不能为空")
|
|
}
|
|
portable := strings.ReplaceAll(output, "\\", "/")
|
|
if filepath.IsAbs(output) || pathpkg.IsAbs(portable) ||
|
|
(len(portable) >= 2 && portable[1] == ':' && ((portable[0] >= 'a' && portable[0] <= 'z') || (portable[0] >= 'A' && portable[0] <= 'Z'))) {
|
|
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 只接受工作目录内的相对路径")
|
|
}
|
|
clean := pathpkg.Clean(portable)
|
|
if clean == ".." || strings.HasPrefix(clean, "../") {
|
|
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不允许使用 .. 逃逸工作目录")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResolveOutputPath returns a symlink-safe destination below baseDir.
|
|
type downloadTarget struct {
|
|
baseRoot *os.Root
|
|
parentRoot *os.Root
|
|
parentInfo os.FileInfo
|
|
parentRelative string
|
|
destinationName string
|
|
absolutePath string
|
|
relativePath string
|
|
}
|
|
|
|
func (target *downloadTarget) close() {
|
|
_ = target.parentRoot.Close()
|
|
_ = target.baseRoot.Close()
|
|
}
|
|
|
|
func (target *downloadTarget) verifyParent() error {
|
|
current, err := downloadRootStat(target.baseRoot, target.parentRelative)
|
|
if err != nil || !os.SameFile(target.parentInfo, current) {
|
|
return fmt.Errorf("LOCAL_PATH_CHANGED: 下载期间输出目录被替换")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ResolveOutputPath(baseDir, output, rawURL, preferredName string) (string, string, error) {
|
|
target, err := openDownloadTarget(baseDir, output, rawURL, preferredName)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer target.close()
|
|
return target.absolutePath, target.relativePath, nil
|
|
}
|
|
|
|
func openDownloadTarget(baseDir, output, rawURL, preferredName string) (*downloadTarget, error) {
|
|
if err := ValidateOutput(output); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(baseDir) == "" {
|
|
var err error
|
|
baseDir, err = localGetwd()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取工作目录失败: %w", err)
|
|
}
|
|
}
|
|
absBase, err := localAbs(baseDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析工作目录失败: %w", err)
|
|
}
|
|
realBase, err := localEvalSymlinks(absBase)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析工作目录失败: %w", err)
|
|
}
|
|
baseRoot, err := openDownloadRoot(realBase)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("打开工作目录失败: %w", err)
|
|
}
|
|
fail := func(err error) (*downloadTarget, error) {
|
|
_ = baseRoot.Close()
|
|
return nil, err
|
|
}
|
|
|
|
rawOutput := strings.TrimSpace(output)
|
|
directoryIntent := rawOutput == "." || strings.HasSuffix(rawOutput, "/") || strings.HasSuffix(rawOutput, string(os.PathSeparator))
|
|
candidate := filepath.Clean(rawOutput)
|
|
if info, statErr := downloadRootStat(baseRoot, candidate); statErr == nil && info.IsDir() {
|
|
directoryIntent = true
|
|
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
|
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: 检查输出路径失败: %w", statErr))
|
|
}
|
|
if directoryIntent {
|
|
candidate = filepath.Join(candidate, SafeFilename(preferredName, rawURL))
|
|
}
|
|
parent := filepath.Dir(candidate)
|
|
if err := ensureSafeParent(baseRoot, parent); err != nil {
|
|
return fail(err)
|
|
}
|
|
parentRoot, err := openDownloadParent(baseRoot, parent)
|
|
if err != nil {
|
|
return fail(fmt.Errorf("固定输出目录失败: %w", err))
|
|
}
|
|
parentInfo, err := downloadRootStat(parentRoot, ".")
|
|
if err != nil {
|
|
_ = parentRoot.Close()
|
|
return fail(fmt.Errorf("读取输出目录身份失败: %w", err))
|
|
}
|
|
currentParent, err := downloadRootStat(baseRoot, parent)
|
|
if err != nil || !os.SameFile(parentInfo, currentParent) {
|
|
_ = parentRoot.Close()
|
|
return fail(fmt.Errorf("LOCAL_PATH_CHANGED: 输出目录在解析期间被替换"))
|
|
}
|
|
destinationName := filepath.Base(candidate)
|
|
if info, statErr := downloadRootLstat(parentRoot, destinationName); statErr == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
_ = parentRoot.Close()
|
|
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标不能是符号链接"))
|
|
}
|
|
if info.IsDir() {
|
|
_ = parentRoot.Close()
|
|
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标是目录"))
|
|
}
|
|
_ = parentRoot.Close()
|
|
return fail(fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在;请选择新的输出路径"))
|
|
} else if !errors.Is(statErr, os.ErrNotExist) {
|
|
_ = parentRoot.Close()
|
|
return fail(fmt.Errorf("检查输出文件失败: %w", statErr))
|
|
}
|
|
return &downloadTarget{
|
|
baseRoot: baseRoot,
|
|
parentRoot: parentRoot,
|
|
parentInfo: parentInfo,
|
|
parentRelative: parent,
|
|
destinationName: destinationName,
|
|
absolutePath: filepath.Join(realBase, candidate),
|
|
relativePath: candidate,
|
|
}, nil
|
|
}
|
|
|
|
// SafeFilename selects a portable basename from a preferred server name or URL.
|
|
func SafeFilename(preferredName, rawURL string) string {
|
|
if name := sanitizeFilename(preferredName); name != "" {
|
|
return name
|
|
}
|
|
if parsed, err := url.Parse(rawURL); err == nil {
|
|
if decoded, decodeErr := url.PathUnescape(filepath.Base(parsed.Path)); decodeErr == nil {
|
|
if name := sanitizeFilename(decoded); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
}
|
|
return "download"
|
|
}
|
|
|
|
// ValidateDownloadURL accepts only public DingTalk and Aliyun OSS HTTPS hosts.
|
|
func ValidateDownloadURL(rawURL string) (*url.URL, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
|
return nil, fmt.Errorf("下载地址必须是受信任域名上的 HTTPS URL")
|
|
}
|
|
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
|
|
if host == "" || net.ParseIP(host) != nil || !allowedDownloadHost(host) {
|
|
return nil, fmt.Errorf("下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host)
|
|
}
|
|
if port := parsed.Port(); port != "" && port != "443" {
|
|
return nil, fmt.Errorf("下载地址只允许 HTTPS 默认端口")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func secureHTTPClient() *http.Client {
|
|
transport := &http.Transport{
|
|
// Do not use environment proxies here. DialContext must resolve and dial
|
|
// the validated download host itself; with a proxy it would receive the
|
|
// proxy address and could not enforce the target host's public-IP policy.
|
|
Proxy: nil,
|
|
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ips, err := lookupDownloadIPs(ctx, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, resolved := range ips {
|
|
if !publicIP(resolved.IP) {
|
|
return nil, fmt.Errorf("下载域名解析到非公网地址 %s", resolved.IP)
|
|
}
|
|
}
|
|
// Dial the already validated address, not the hostname, to avoid a
|
|
// second DNS lookup opening a rebinding window.
|
|
var lastErr error
|
|
for _, resolved := range ips {
|
|
conn, dialErr := dialDownloadIP(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
|
|
if dialErr == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = dialErr
|
|
}
|
|
return nil, lastErr
|
|
},
|
|
}
|
|
client := &http.Client{Transport: transport, Timeout: downloadTimeout}
|
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 5 {
|
|
return fmt.Errorf("下载重定向次数超过上限")
|
|
}
|
|
if _, err := ValidateDownloadURL(req.URL.String()); err != nil {
|
|
return err
|
|
}
|
|
// net/http copies arbitrary request headers from the initial request to
|
|
// every redirect. Never forward service-provided download credentials to
|
|
// a different origin, even when both hosts are on the download allowlist.
|
|
if len(via) > 0 && !sameDownloadOrigin(via[0].URL, req.URL) {
|
|
req.Header = make(http.Header)
|
|
}
|
|
return nil
|
|
}
|
|
return client
|
|
}
|
|
|
|
func sameDownloadOrigin(left, right *url.URL) bool {
|
|
return downloadOrigin(left) == downloadOrigin(right)
|
|
}
|
|
|
|
func downloadOrigin(parsed *url.URL) string {
|
|
port := parsed.Port()
|
|
if port == "" {
|
|
port = "443"
|
|
}
|
|
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
|
|
return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port)
|
|
}
|
|
|
|
func allowedDownloadHost(host string) bool {
|
|
return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") ||
|
|
(strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal"))
|
|
}
|
|
|
|
func publicIP(ip net.IP) bool {
|
|
addr, ok := netip.AddrFromSlice(ip)
|
|
if !ok {
|
|
return false
|
|
}
|
|
addr = addr.Unmap()
|
|
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() {
|
|
return false
|
|
}
|
|
for _, prefix := range nonPublicPrefixes {
|
|
if prefix.Contains(addr) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
var nonPublicPrefixes = []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
|
|
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
|
|
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
|
|
netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks
|
|
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
|
|
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3
|
|
netip.MustParsePrefix("240.0.0.0/4"), // reserved
|
|
netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation
|
|
}
|
|
|
|
func ensureSafeParent(root *os.Root, parent string) error {
|
|
if parent == "." {
|
|
return nil
|
|
}
|
|
current := "."
|
|
for _, part := range strings.Split(parent, string(os.PathSeparator)) {
|
|
current = filepath.Join(current, part)
|
|
info, statErr := downloadRootLstat(root, current)
|
|
if errors.Is(statErr, os.ErrNotExist) {
|
|
if err := downloadRootMkdir(root, current, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
|
|
return fmt.Errorf("创建输出目录失败: %w", err)
|
|
}
|
|
info, statErr = downloadRootLstat(root, current)
|
|
}
|
|
if statErr != nil {
|
|
return fmt.Errorf("检查输出目录失败: %w", statErr)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 父路径必须是非符号链接目录")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func createDownloadTempInRoot(root *os.Root) (downloadTempFile, string, error) {
|
|
name := fmt.Sprintf(".dws-download-%d-%d", os.Getpid(), downloadTempCounter.Add(1))
|
|
file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return file, name, nil
|
|
}
|
|
|
|
func publishTempFile(root *os.Root, tempName, destinationName string) error {
|
|
if err := downloadRootLink(root, tempName, destinationName); err != nil {
|
|
if errors.Is(err, os.ErrExist) {
|
|
return fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在")
|
|
}
|
|
return fmt.Errorf("发布下载文件失败: %w", err)
|
|
}
|
|
if err := downloadRootRemove(root, tempName); err != nil {
|
|
return fmt.Errorf("清理下载临时文件失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sanitizeFilename(raw string) string {
|
|
normalized := strings.ReplaceAll(raw, "\\", "/")
|
|
if strings.TrimSpace(normalized) != normalized {
|
|
return ""
|
|
}
|
|
name := filepath.Base(normalized)
|
|
if name == "" || name == "." || name == ".." || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
|
|
return ""
|
|
}
|
|
for _, char := range name {
|
|
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) {
|
|
return ""
|
|
}
|
|
}
|
|
stem := strings.ToUpper(strings.TrimRight(strings.SplitN(name, ".", 2)[0], " ."))
|
|
if stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL" ||
|
|
(len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && stem[3] >= '1' && stem[3] <= '9') {
|
|
return ""
|
|
}
|
|
return name
|
|
}
|