Skip to content

Commit d285154

Browse files
authored
Merge pull request #1325 from chaitin/ci-fix-security-rescan-findings
fix(management,mcp-server,yanshi): close the second security scan round
2 parents dccd614 + 4a4d92d commit d285154

50 files changed

Lines changed: 1497 additions & 226 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎compose.yaml‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ services:
4343
test: curl -k -f https://localhost:1443/api/open/health
4444
environment:
4545
- MGT_PG=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable
46+
# Optional override for the control channel token that tcontrollerd has to
47+
# present before it may subscribe. Leave it empty to use
48+
# ${SAFELINE_DIR}/resources/sock/mgt_grpc_token, which this container
49+
# creates and the tengine container reads.
50+
- MGT_GRPC_TOKEN=${MGT_GRPC_TOKEN:-}
4651
depends_on:
4752
- postgres
4853
- fvm
@@ -83,6 +88,9 @@ services:
8388
environment:
8489
- TCD_MGT_API=https://${SUBNET_PREFIX}.4:1443/api/open/publish/server
8590
- TCD_SNSERVER=${SUBNET_PREFIX}.5:8000
91+
# Optional override for the control channel token; empty means the token
92+
# file in ${SAFELINE_DIR}/resources/sock is used.
93+
- TCD_MGT_TOKEN=${MGT_GRPC_TOKEN:-}
8694
# deprecated
8795
- SNSERVER_ADDR=${SUBNET_PREFIX}.5:8000
8896
- CHAOS_ADDR=${SUBNET_PREFIX}.10
@@ -140,4 +148,4 @@ services:
140148
- ${SAFELINE_DIR}/resources/chaos:/app/chaos
141149
networks:
142150
safeline-ce:
143-
ipv4_address: ${SUBNET_PREFIX}.10
151+
ipv4_address: ${SUBNET_PREFIX}.10

‎management/tcontrollerd/config.yml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,9 @@
22
log:
33
output: stdout # "stdout", "stderr" or file path
44
level: debug # "debug", "info", "warn" or "error"
5-
mgt_addr: 169.254.0.4:9002 # gRPC addr of mgt-api webserver
5+
mgt_addr: 169.254.0.4:9002 # gRPC addr of mgt-api webserver
6+
# Shared secret of the control channel. mgt-api creates it in a directory that
7+
# both containers mount (see the safeLine compose file); set MGT_TOKEN_FILE to
8+
# point somewhere else, or TCD_MGT_TOKEN to pass the value through the
9+
# environment instead.
10+
# mgt_token_file: /app/sock/mgt_grpc_token

‎management/tcontrollerd/controller/controller.go‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
package controller
22

33
import (
4+
"context"
5+
"os"
6+
"strings"
7+
48
"google.golang.org/grpc"
59
"google.golang.org/grpc/credentials/insecure"
10+
"google.golang.org/grpc/metadata"
611

12+
"chaitin.cn/dev/go/errors"
713
"chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/config"
814
"chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/log"
915
pb "chaitin.cn/patronus/safeline-2/management/tcontrollerd/proto/website"
@@ -13,8 +19,26 @@ var (
1319
logger = log.GetLogger("controller")
1420
)
1521

22+
const (
23+
// TokenEnv holds the control channel token itself.
24+
TokenEnv = "TCD_MGT_TOKEN"
25+
26+
// tokenMetadataKey is the metadata key that carries the token to the
27+
// management server. gRPC metadata keys have to be lower case.
28+
tokenMetadataKey = "mgt-token"
29+
)
30+
1631
func Handle() error {
1732
logger.Infof("Connect mgt-webserver at %s", config.GlobalConfig.MgtWebserver)
33+
34+
// The management server only accepts a subscription that proves it belongs
35+
// to this installation.
36+
token, err := controlToken()
37+
if err != nil {
38+
logger.Errorf("Fail to read the control channel token: %v", err)
39+
return err
40+
}
41+
1842
gRPCConn, err := grpc.Dial(config.GlobalConfig.MgtWebserver, []grpc.DialOption{
1943
grpc.WithTransportCredentials(insecure.NewCredentials()),
2044
}...)
@@ -33,9 +57,34 @@ func Handle() error {
3357
}
3458
}(gRPCConn)
3559

36-
if err = websiteHandler(wsClient); err != nil {
60+
ctx := metadata.AppendToOutgoingContext(context.Background(), tokenMetadataKey, token)
61+
if err = websiteHandler(ctx, wsClient); err != nil {
3762
return err
3863
}
3964

4065
return nil
4166
}
67+
68+
// controlToken returns the shared secret of the control channel.
69+
//
70+
// The value is taken from the environment when it is set there, and from the
71+
// token file otherwise. The management server creates that file in the
72+
// directory both containers mount, so the standard deployment needs no
73+
// configuration.
74+
func controlToken() (string, error) {
75+
if token := strings.TrimSpace(os.Getenv(TokenEnv)); token != "" {
76+
return token, nil
77+
}
78+
79+
content, err := os.ReadFile(config.GlobalConfig.MgtTokenFile)
80+
if err != nil {
81+
return "", errors.Wrapf(err, "read the control channel token from %s", config.GlobalConfig.MgtTokenFile)
82+
}
83+
84+
token := strings.TrimSpace(string(content))
85+
if token == "" {
86+
return "", errors.New("the control channel token file " + config.GlobalConfig.MgtTokenFile + " is empty")
87+
}
88+
89+
return token, nil
90+
}

‎management/tcontrollerd/controller/validation.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package controller
22

33
import (
44
"fmt"
5+
"path/filepath"
56
"regexp"
67
"strconv"
78
"strings"
@@ -25,8 +26,44 @@ var (
2526
// nginx "server_name" directive, so anything that could terminate the
2627
// directive (or start another one) must not pass.
2728
serverNameLabelPattern = regexp.MustCompile(`^[A-Za-z0-9_]([A-Za-z0-9_-]*[A-Za-z0-9_])?$`)
29+
30+
// certFilenamePattern matches the file name of a certificate or a key. The
31+
// name is rendered into "ssl_certificate" and "ssl_certificate_key", and
32+
// it is joined to the certificate directory, so only a plain file name
33+
// without a path separator, a control character or any nginx syntax may
34+
// pass.
35+
certFilenamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
2836
)
2937

38+
// maxCertFilenameLength bounds a certificate file name.
39+
const maxCertFilenameLength = 255
40+
41+
// validateCertFilename accepts the name of a file in the certificate directory.
42+
//
43+
// The management server validates the same value before it stores it, but this
44+
// side cannot rely on that: the configuration is pushed over the network, and a
45+
// name carrying nginx syntax would turn into directives that run commands.
46+
func validateCertFilename(name string) error {
47+
// An empty name means "this site is served over plain HTTP".
48+
if name == "" {
49+
return nil
50+
}
51+
52+
if len(name) > maxCertFilenameLength {
53+
return fmt.Errorf("invalid certificate file name %q: longer than %d characters", name, maxCertFilenameLength)
54+
}
55+
56+
if name != filepath.Base(name) || name == "." || name == ".." {
57+
return fmt.Errorf("invalid certificate file name %q: only a file name without a directory is allowed", name)
58+
}
59+
60+
if !certFilenamePattern.MatchString(name) {
61+
return fmt.Errorf("invalid certificate file name %q: only letters, digits, dot, dash and underscore are allowed", name)
62+
}
63+
64+
return nil
65+
}
66+
3067
// validatePort accepts the decimal notation of a TCP port. It rejects any
3168
// other value because the port is interpolated into a "listen" directive.
3269
func validatePort(port string) error {

‎management/tcontrollerd/controller/website.go‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ const (
3030
nginxBackupFilePrefix = "BAK_IF_backend_"
3131
nginxFileMode = 0644
3232

33+
// nginxCertsPath is the directory the ssl_certificate templates below point
34+
// at; certificate file names have to stay inside it.
35+
nginxCertsPath = "/etc/nginx/certs"
36+
3337
HttpsScheme = "https"
3438
DefaultHttpsPort = "443"
3539
)
@@ -58,6 +62,19 @@ func generateNginxConfig(website *model.WebsiteConfig) (string, error) {
5862
sslCertFilename := ""
5963
sslCertKeyFilename := ""
6064
if website.KeyFilename != "" && website.CertFilename != "" {
65+
for _, filename := range []string{website.CertFilename, website.KeyFilename} {
66+
if err := validateCertFilename(filename); err != nil {
67+
return "", err
68+
}
69+
70+
// The template renders the name into an absolute path, so an
71+
// explicit check backs up the name validation above: the resolved
72+
// path has to stay inside the certificate directory.
73+
if filepath.Dir(filepath.Join(nginxCertsPath, filename)) != nginxCertsPath {
74+
return "", fmt.Errorf("invalid certificate file name %q: it leaves %s", filename, nginxCertsPath)
75+
}
76+
}
77+
6178
sslFlag = " ssl" // with a blank ahead
6279
sslCertFilename = fmt.Sprintf(certTpl, website.CertFilename)
6380
sslCertKeyFilename = fmt.Sprintf(certKeyTpl, website.KeyFilename)
@@ -110,11 +127,28 @@ func generateFullConfigAndReload(msg []byte) error {
110127
return err
111128
}
112129

130+
if err := utils.EnsureDir(nginxConfigPath); err != nil {
131+
return err
132+
}
133+
113134
configFilename := make(map[string]struct{})
114135
for _, website := range websites {
115136
configFilename[fmt.Sprintf("%s%d", nginxFilePrefix, website.Id)] = struct{}{}
116137
}
117-
filepath.Walk(nginxConfigPath, func(path string, info fs.FileInfo, err error) error {
138+
139+
if err := filepath.Walk(nginxConfigPath, func(path string, info fs.FileInfo, err error) error {
140+
// filepath.Walk reports the entries it could not read through err and
141+
// leaves info nil in that case: touching info before this check is a
142+
// nil pointer dereference that takes the whole daemon down.
143+
if err != nil {
144+
logger.Warnf("Skip %s: %v", path, err)
145+
return nil
146+
}
147+
148+
if info == nil || info.IsDir() {
149+
return nil
150+
}
151+
118152
_, ok := configFilename[info.Name()]
119153
if !ok && strings.HasPrefix(info.Name(), nginxFilePrefix) {
120154
if err := os.Remove(path); err != nil {
@@ -124,7 +158,9 @@ func generateFullConfigAndReload(msg []byte) error {
124158
}
125159

126160
return nil
127-
})
161+
}); err != nil {
162+
return err
163+
}
128164

129165
for _, website := range websites {
130166
if err := generateConfigAndReload(&website); err != nil {
@@ -231,8 +267,8 @@ func sendResponse(stream pb.Website_SubscribeClient, eventType string, errMsg []
231267
})
232268
}
233269

234-
func websiteHandler(wc pb.WebsiteClient) error {
235-
stream, err := wc.Subscribe(context.Background())
270+
func websiteHandler(ctx context.Context, wc pb.WebsiteClient) error {
271+
stream, err := wc.Subscribe(ctx)
236272
if err != nil {
237273
logger.Errorf("Subscribe failed: %v", err)
238274
return err

‎management/tcontrollerd/pkg/config/config.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ var (
1010
GlobalConfig = DefaultGlobalConfig()
1111
)
1212

13+
// DefaultMgtTokenFile is where the control channel token is read from by
14+
// default. The standard deployment mounts the same host directory at
15+
// /app/sock inside the management container, which creates the file, and
16+
// inside the tengine container, which is where tcontrollerd runs.
17+
const DefaultMgtTokenFile = "/app/sock/mgt_grpc_token"
18+
1319
func InitConfigs(configFilePath string) error {
1420
s, err := settings.New(configFilePath)
1521
if err != nil {
@@ -28,5 +34,15 @@ func InitConfigs(configFilePath string) error {
2834
GlobalConfig.MgtWebserver = v
2935
}
3036

37+
// The token file is optional in the configuration file: the default is
38+
// where the management server puts it.
39+
if err := s.Unmarshal("mgt_token_file", &GlobalConfig.MgtTokenFile); err != nil {
40+
return err
41+
}
42+
43+
if v, ok := os.LookupEnv("MGT_TOKEN_FILE"); ok {
44+
GlobalConfig.MgtTokenFile = v
45+
}
46+
3147
return nil
3248
}

‎management/tcontrollerd/pkg/config/global.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package config
33
type Config struct {
44
Log LogConfig
55
MgtWebserver string
6+
MgtTokenFile string
67
}
78

89
func DefaultGlobalConfig() Config {
910
return Config{
1011
Log: DefaultLogConfig(),
1112
MgtWebserver: "169.254.0.4:9002",
13+
MgtTokenFile: DefaultMgtTokenFile,
1214
}
1315
}

‎management/tcontrollerd/pkg/log/log.go‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,17 @@ func InitLogger() error {
5151
fileFlag = fileFlag | os.O_CREATE
5252
}
5353

54-
if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, os.ModePerm); err != nil {
54+
// os.ModePerm (0777) let every local user rewrite the audit log. The
55+
// file is created with an owner/group readable mode instead.
56+
if fp, err := os.OpenFile(config.GlobalConfig.Log.Output, fileFlag, logFileMode); err != nil {
5557
return fmt.Errorf("failed to open log file: %s", err.Error())
5658
} else {
5759
log.SetOutput(log.AllLoggers, log.NewLockOutput(fp))
5860
}
61+
62+
// The mode above only applies when the file is created, so a log file
63+
// that an older release left world writable is tightened here.
64+
tightenLogFileMode(config.GlobalConfig.Log.Output)
5965
}
6066

6167
// hook
@@ -67,6 +73,28 @@ func InitLogger() error {
6773
return nil
6874
}
6975

76+
// logFileMode is the permission the log file is created with. Logs are audit
77+
// material: they must not be writable by other local users.
78+
const logFileMode os.FileMode = 0640
79+
80+
// looseLogFileMask matches the group and world write bits.
81+
const looseLogFileMask os.FileMode = 0o022
82+
83+
// tightenLogFileMode removes the group and world write bits from an existing
84+
// log file, so that a log file created by an older release cannot stay
85+
// tamperable by other local users.
86+
func tightenLogFileMode(path string) {
87+
info, err := os.Stat(path)
88+
if err != nil || info.Mode().Perm()&looseLogFileMask == 0 {
89+
return
90+
}
91+
92+
log.Warnf("Log file %s is writable by other users, setting mode to %#o", path, logFileMode)
93+
if err := os.Chmod(path, logFileMode); err != nil {
94+
log.Warnf("Cannot tighten the mode of log file %s: %s", path, err)
95+
}
96+
}
97+
7098
type RuntimeHook struct{}
7199

72100
func (h *RuntimeHook) Levels() []logrus.Level {

‎management/tcontrollerd/utils/file.go‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,39 @@ func FilesExist(paths ...string) (bool, error) {
5050
}
5151

5252
func RenameWriteFile(filename string, data []byte, perm os.FileMode) error {
53-
randFileName := filename + ".tmp." + RandStr(8)
54-
if err := ioutil.WriteFile(randFileName, data, perm); err != nil {
53+
// os.CreateTemp creates the file with O_CREATE|O_EXCL and mode 0600, so a
54+
// file or symlink planted at the temporary path beforehand cannot be
55+
// followed. The previous implementation guessed the name with math/rand and
56+
// wrote through ioutil.WriteFile, which follows symlinks.
57+
tmpFile, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp.")
58+
if err != nil {
5559
return err
5660
}
57-
return os.Rename(randFileName, filename)
61+
tmpName := tmpFile.Name()
62+
63+
renamed := false
64+
defer func() {
65+
if !renamed {
66+
os.Remove(tmpName)
67+
}
68+
}()
69+
70+
if err = tmpFile.Chmod(perm); err != nil {
71+
tmpFile.Close()
72+
return err
73+
}
74+
if _, err = tmpFile.Write(data); err != nil {
75+
tmpFile.Close()
76+
return err
77+
}
78+
if err = tmpFile.Close(); err != nil {
79+
return err
80+
}
81+
if err = os.Rename(tmpName, filename); err != nil {
82+
return err
83+
}
84+
renamed = true
85+
return nil
5886
}
5987

6088
func EnsureRenameWriteFile(path string, data []byte, mode os.FileMode) error {

0 commit comments

Comments
 (0)