Scert/backend/internal/nginxactivation/files.go

143 lines
4.2 KiB
Go

//go:build linux
package nginxactivation
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"syscall"
"golang.org/x/sys/unix"
)
func readRegular(root *os.Root, name string, limit int64) ([]byte, error) {
info, err := root.Lstat(name)
if err != nil {
return nil, err
}
if err := validateStateFile(info, limit); err != nil {
return nil, err
}
file, err := root.OpenFile(name, os.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
if err != nil {
return nil, err
}
defer file.Close()
opened, err := file.Stat()
if err != nil {
return nil, err
}
if err := validateStateFile(opened, limit); err != nil {
return nil, err
}
if !os.SameFile(info, opened) {
return nil, errors.New("激活状态文件在读取期间发生变化")
}
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(data)) > limit {
return nil, errors.Join(errors.New("读取激活状态失败"), err)
}
return data, nil
}
func validateStateFile(info os.FileInfo, limit int64) error {
if !info.Mode().IsRegular() || info.Size() > limit {
return errors.New("激活状态必须是大小受限的普通文件")
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Uid != uint32(os.Geteuid()) || stat.Nlink != 1 || info.Mode().Perm()&0o022 != 0 {
return errors.New("激活状态文件所有者、链接数或写权限不可信")
}
return nil
}
func writeExclusive(root *os.Root, name string, data []byte) error {
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
_, writeErr := file.Write(data)
syncErr := file.Sync()
return errors.Join(writeErr, syncErr, file.Close())
}
func writeAtomic(root *os.Root, name string, data []byte) error {
if info, err := root.Lstat(name); err == nil {
if err := validateStateFile(info, 64<<10); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
id, err := newID()
if err != nil {
return err
}
// 失败的临时文件保留供诊断,不覆盖已有快照。
temporary := ".pending-" + id
if err := writeExclusive(root, temporary, data); err != nil {
return err
}
if err := root.Rename(temporary, name); err != nil {
return err
}
directory, err := root.Open(".")
if err != nil {
return err
}
return errors.Join(directory.Sync(), directory.Close())
}
func newID() (string, error) {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
return "", err
}
return hex.EncodeToString(data[:]), nil
}
func validateDirectory(directory string) error {
// 目录来自本地部署策略,仍拒绝可改变 Nginx 指令语义的字符。
for _, char := range directory {
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '/' || char == '-' || char == '_' || char == '.') {
return errors.New("Nginx 激活目录包含不允许的字符")
}
}
return validatePrivateDirectory(directory)
}
func validatePrivateDirectory(directory string) error {
if !filepath.IsAbs(directory) || filepath.Clean(directory) != directory || directory == string(filepath.Separator) {
return errors.New("激活状态目录必须是独立的规范绝对目录")
}
for path := directory; ; path = filepath.Dir(path) {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("激活目录及其父路径必须是真实目录:%w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("激活目录及其父路径必须是真实目录")
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || (stat.Uid != 0 && stat.Uid != uint32(os.Geteuid())) {
return errors.New("激活目录及其父路径的所有者不可信")
}
if path == directory && (info.Mode().Perm() != 0o700 || stat.Uid != uint32(os.Geteuid())) {
return errors.New("激活目录只能由激活器所属用户访问")
}
// 仅允许 root 所有且带粘滞位的共享父目录;其他用户不能替换受信子目录。
if info.Mode().Perm()&0o022 != 0 && !(stat.Uid == 0 && info.Mode()&os.ModeSticky != 0) {
return errors.New("激活目录的父路径允许非受信用户写入")
}
if filepath.Dir(path) == path {
break
}
}
return nil
}