Skip to content

Commit 7890452

Browse files
authored
Merge pull request #1332 from chaitin/ci-fix-fifth-scan-findings
fix(management,mcp-server): attribute control channel answers and bou…
2 parents cc16ef4 + 76a49f7 commit 7890452

12 files changed

Lines changed: 338 additions & 12 deletions

File tree

‎management/tcontrollerd/controller/website.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"path/filepath"
1313
"strings"
1414

15+
"chaitin.cn/dev/go/errors"
1516
"chaitin.cn/patronus/safeline-2/management/tcontrollerd/model"
1617
"chaitin.cn/patronus/safeline-2/management/tcontrollerd/pkg/ngcmd"
1718
pb "chaitin.cn/patronus/safeline-2/management/tcontrollerd/proto/website"
@@ -132,6 +133,16 @@ func generateFullConfigAndReload(msg []byte) error {
132133
return err
133134
}
134135

136+
// "null" is what a query that returned nothing looks like on the wire, and
137+
// a full push that carries it would delete every configuration of the
138+
// installation. The management server always sends an array, so a payload
139+
// that is not a list is refused and the current configuration is kept: an
140+
// installation that really has no site sends an empty array, which is
141+
// applied.
142+
if websites == nil {
143+
return errors.New("the full push does not carry a list of sites, keeping the current configuration")
144+
}
145+
135146
if err := utils.EnsureDir(nginxConfigPath); err != nil {
136147
return err
137148
}

‎management/webserver/pkg/fvm/fsl/quote.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ func appendEscapedByte(buf []byte, b byte) []byte {
2828
case '\\':
2929
return append(buf, `\\`...)
3030
default:
31-
return append(buf, fmt.Sprintf("\\x%x", b)...)
31+
// The escape has to be two digits wide: "\x5" followed by a literal
32+
// "a" is read by the lexer as "\x5a" ("Z"), so a rule whose comment or
33+
// header value ends with a control byte would silently change the
34+
// source that is handed to the compiler.
35+
return append(buf, fmt.Sprintf("\\x%02x", b)...)
3236
}
3337
}
3438

‎management/webserver/rpc/website.go‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ func Publish(msg []byte, eventType string) error {
3131
if sub == nil {
3232
return errors.New("Service is abnormal, and the nginx conf cannot be updated for the time being. Please go to the shell to check the relevant logs")
3333
}
34+
35+
// Only one exchange may be outstanding on the stream. The response carries
36+
// no request id, so two overlapping callers that share the single slot below
37+
// would each read the answer of the other: one would commit a database
38+
// change that tcontrollerd refused, the other would roll back one it
39+
// applied. The generated client does not allow concurrent sends on one
40+
// stream either.
41+
sub.exchangeMu.Lock()
42+
defer sub.exchangeMu.Unlock()
43+
44+
// A response whose own request already gave up is still in the slot, and
45+
// this call would read it as its own answer.
46+
discardPendingResponses(sub)
47+
3448
err := sub.stream.Send(&pb.Event{
3549
Type: eventType,
3650
Msg: msg,
@@ -59,18 +73,49 @@ func Publish(msg []byte, eventType string) error {
5973
return nil
6074
}
6175
case <-rspTimeoutTicker.C:
76+
// The device did not answer, so what it applied is unknown: an
77+
// answer that arrives later would be read by the next request as
78+
// if it belonged to it. The stream is dropped instead, which
79+
// leaves the slot this call abandons unreachable and makes
80+
// tcontrollerd reconnect and synchronise with a full push.
81+
clearSubscriber(sub)
82+
select {
83+
case sub.errCh <- errors.New("Wait timeout for updating result"):
84+
default:
85+
}
86+
6287
return errors.New("Wait timeout for updating result")
6388
}
6489
}
6590
}
6691

92+
// discardPendingResponses drops the responses that are sitting in the slot.
93+
//
94+
// It is called while the exchange of the caller is held, so nothing in the slot
95+
// can belong to a request that is still waiting.
96+
func discardPendingResponses(sc *StreamClient) {
97+
for {
98+
select {
99+
case rsp := <-sc.rspCh:
100+
logger.Warnf("Discarding an unclaimed control channel response of type %s", rsp.GetType())
101+
default:
102+
return
103+
}
104+
}
105+
}
106+
67107
// StreamClient is the instance for every client stream connected
68108
type StreamClient struct {
69109
stream pb.Website_SubscribeServer
70110
timer *time.Timer // timer for timeout
71111
errCh chan error
72112
rspCh chan *pb.Response
73113
quit chan struct{} // quit stream client gracefully
114+
115+
// exchangeMu serialises the send-and-wait of Publish. The response of a
116+
// request is not labelled with the request it answers, so at most one
117+
// request may be in flight at a time.
118+
exchangeMu sync.Mutex
74119
}
75120

76121
// currentSubscriber returns the active subscriber, or nil when no stream is
@@ -171,7 +216,18 @@ func (sc *StreamClient) recvLoop() {
171216
if rsp.Type != Pong {
172217
// receive response
173218
logger.Infof("Recv updating website rsp: err(%t), msg(%s)", rsp.GetErr(), rsp.GetMsg())
174-
sc.rspCh <- rsp
219+
220+
// This loop is also the goroutine that reads the keepalive pongs
221+
// and resets the timer, so it must not block on a full slot: it
222+
// would disconnect a stream whose peer is perfectly healthy. Only
223+
// one exchange is outstanding at a time (see Publish) and it
224+
// empties the slot before it sends, so a slot that is already full
225+
// holds a response nobody is waiting for.
226+
select {
227+
case sc.rspCh <- rsp:
228+
default:
229+
logger.Warn("Discarding a control channel response that no request is waiting for")
230+
}
175231
continue
176232
}
177233
sc.timer.Reset(KeepaliveTimeout)
@@ -195,7 +251,21 @@ func GetWebsiteServer() *WebsiteServer {
195251
func publishFullWebsite() error {
196252
var websites []model.Website
197253
db := database.GetDB().DB
198-
db.Model(&model.Website{}).Find(&websites)
254+
255+
// A failed query leaves the slice empty, and an empty list means "this
256+
// installation has no sites" to tcontrollerd: it would delete every nginx
257+
// configuration it manages. The error has to stop the push instead.
258+
if err := db.Model(&model.Website{}).Find(&websites).Error; err != nil {
259+
return errors.Wrap(err, "load websites")
260+
}
261+
262+
if websites == nil {
263+
// A nil slice marshals to "null", which tcontrollerd reads as a
264+
// malformed push. An installation that really has no site sends an
265+
// empty list.
266+
websites = []model.Website{}
267+
}
268+
199269
byteWebsites, err := json.Marshal(&websites)
200270
if err != nil {
201271
return err

‎management/webserver/utils/httpclient.go‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package utils
22

33
import (
4+
"net"
45
"net/http"
56
"net/url"
67
"os"
@@ -10,6 +11,21 @@ import (
1011

1112
const proxyName = "HTTPS_PROXY"
1213

14+
const (
15+
// httpClientTimeout bounds one request end to end, from the connection
16+
// attempt to the last byte of the body.
17+
httpClientTimeout = 15 * time.Second
18+
19+
// httpClientDialTimeout bounds the connection attempt, and
20+
// httpClientTLSHandshakeTimeout the handshake that follows it.
21+
httpClientDialTimeout = 5 * time.Second
22+
httpClientTLSHandshakeTimeout = 5 * time.Second
23+
24+
// httpClientResponseHeaderTimeout bounds the wait for the response header
25+
// once the request was written.
26+
httpClientResponseHeaderTimeout = 10 * time.Second
27+
)
28+
1329
var (
1430
httpClientOnce sync.Once
1531
httpClient *http.Client
@@ -20,14 +36,27 @@ var (
2036
// The client is built once: the API handlers and the cron jobs call this at the
2137
// same time, and the unsynchronized lazy assignment used before let two of them
2238
// build (and see) a client at the same time.
39+
//
40+
// Every phase of a request is bounded. The client is used by endpoints that any
41+
// caller can reach, such as the upgrade tip lookup, and the endpoints it talks
42+
// to are remote: an upstream that accepts the connection and then stops
43+
// answering would otherwise hold the handler goroutine and its socket for as
44+
// long as the connection lives, which a caller can repeat without limit.
2345
func GetHTTPClient() *http.Client {
2446
httpClientOnce.Do(func() {
2547
// Certificates are verified: this client talks to the telemetry
2648
// endpoint and to the upgrade server, both of which are remote and can
2749
// be spoofed by a man in the middle when verification is disabled.
2850
tr := &http.Transport{
29-
MaxIdleConns: 10,
30-
IdleConnTimeout: 30 * time.Second,
51+
DialContext: (&net.Dialer{
52+
Timeout: httpClientDialTimeout,
53+
KeepAlive: 30 * time.Second,
54+
}).DialContext,
55+
MaxIdleConns: 10,
56+
IdleConnTimeout: 30 * time.Second,
57+
TLSHandshakeTimeout: httpClientTLSHandshakeTimeout,
58+
ResponseHeaderTimeout: httpClientResponseHeaderTimeout,
59+
ExpectContinueTimeout: 1 * time.Second,
3160
}
3261

3362
proxyUrl, existed := os.LookupEnv(proxyName)
@@ -36,7 +65,7 @@ func GetHTTPClient() *http.Client {
3665
tr.Proxy = http.ProxyURL(uri)
3766
}
3867

39-
httpClient = &http.Client{Transport: tr}
68+
httpClient = &http.Client{Transport: tr, Timeout: httpClientTimeout}
4069
})
4170

4271
return httpClient

‎mcp_server/README.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ instances:
4444
token_file: "/run/secrets/production-a.token"
4545
timeout: 30
4646
debug: false
47+
ca_file: ""
4748
insecure_skip_verify: false
4849

4950
- id: "production-b"
@@ -52,15 +53,23 @@ instances:
5253
token_file: "/run/secrets/production-b.token"
5354
timeout: 30
5455
debug: false
56+
ca_file: ""
5557
insecure_skip_verify: false
5658
```
5759
5860
`LISTEN_ADDRESS` and `LISTEN_PORT` override listener settings. There is deliberately no global `SAFELINE_API_TOKEN`: every instance uses a separate token file.
5961

62+
`ca_file` is the certificate authority that signed the certificate of the
63+
instance. A SafeLine console serves the certificate it generated for itself,
64+
which no public authority signed: point `ca_file` at that certificate (or at the
65+
authority that issued it) and the connection stays verified. Relative paths are
66+
resolved from the directory of the configuration file.
67+
6068
`insecure_skip_verify` disables TLS verification for the SafeLine API. The API
6169
Token is an administrator credential that is sent on every request, so leave the
62-
setting at `false` and give the instance a certificate the server trusts. Enable
63-
it only for a lab instance whose certificate cannot be replaced.
70+
setting at `false` and use `ca_file` for an instance with a self-signed
71+
certificate. Setting it to `true` logs a warning at startup, and it cannot be
72+
combined with `ca_file`.
6473

6574
Each `display_name` must be unique, ignoring letter case, and must not match another instance's `id`. During server discovery (or legacy initialization), the server publishes only the `display_name` to `instance_id` mappings as Server Instructions. This lets a user refer to a friendly name while the AI still calls `get_attack_events` with the stable, explicit `instance_id`. Instance addresses and credentials are never included in those instructions.
6675

‎mcp_server/config.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ instances:
2121
token_file: "/run/secrets/safeline-a.token"
2222
timeout: 30
2323
debug: false
24+
# ca_file is the certificate authority that signed the certificate of the
25+
# instance. A SafeLine console serves the certificate it generated for
26+
# itself, which no public authority signed: point this at that certificate
27+
# (or at its CA) instead of switching the check off.
28+
ca_file: ""
2429
# Leave TLS verification enabled: the SafeLine API Token is an
2530
# administrator credential and disabling verification lets anyone on the
2631
# path read it. Set this to true only for a lab instance with an
@@ -33,4 +38,5 @@ instances:
3338
token_file: "/run/secrets/safeline-b.token"
3439
timeout: 30
3540
debug: false
41+
ca_file: ""
3642
insecure_skip_verify: false

‎mcp_server/internal/api/client.go‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package api
33
import (
44
"context"
55
"crypto/tls"
6+
"crypto/x509"
67
"encoding/json"
78
"fmt"
89
"io"
910
"net/http"
1011
"net/url"
12+
"os"
1113
"strings"
1214
"time"
1315

@@ -26,6 +28,25 @@ const (
2628
maxRedirects = 10
2729
)
2830

31+
// loadRootCAs reads the certificate authority the instance is issued by.
32+
//
33+
// It is a file rather than the system pool because the installation that the
34+
// MCP server talks to usually carries the certificate it generated for itself,
35+
// which no system pool knows about.
36+
func loadRootCAs(path string) (*x509.CertPool, error) {
37+
content, err := os.ReadFile(path)
38+
if err != nil {
39+
return nil, errors.Wrap(err, "read ca_file failed")
40+
}
41+
42+
roots := x509.NewCertPool()
43+
if !roots.AppendCertsFromPEM(content) {
44+
return nil, errors.New("ca_file does not contain a PEM encoded certificate")
45+
}
46+
47+
return roots, nil
48+
}
49+
2950
// Client is the transport used by a single SafeLine instance. Its request
3051
// method is intentionally unexported; APIClient exposes only approved read-only
3152
// operations.
@@ -35,7 +56,7 @@ type Client struct {
3556
headers http.Header
3657
}
3758

38-
func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, token string) (*Client, error) {
59+
func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, caFile, token string) (*Client, error) {
3960
parsedURL, err := url.Parse(strings.TrimSpace(baseURL))
4061
if err != nil {
4162
return nil, errors.Wrap(err, "parse base_url failed")
@@ -51,6 +72,27 @@ func newClient(baseURL string, timeout time.Duration, insecureSkipVerify bool, t
5172
transport := &http.Transport{
5273
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}, // #nosec G402 -- deployment-controlled compatibility setting
5374
}
75+
76+
if caFile != "" {
77+
// The console of a SafeLine installation serves a certificate that no
78+
// public authority signed, so the choice used to be between switching
79+
// verification off and failing every request. Pinning the authority of
80+
// the instance keeps it on, which is what keeps the deployment token in
81+
// the request header out of the hands of anything on the path.
82+
roots, err := loadRootCAs(caFile)
83+
if err != nil {
84+
return nil, err
85+
}
86+
transport.TLSClientConfig.RootCAs = roots
87+
}
88+
89+
if insecureSkipVerify {
90+
logger.With("base_url", parsedURL.Redacted()).Warn(
91+
"insecure_skip_verify is set: the certificate of this instance is not checked, " +
92+
"so anything on the path can read the API token of the deployment. " +
93+
"Set ca_file to the certificate authority of the instance instead.")
94+
}
95+
5496
client := &Client{
5597
baseURL: parsedURL,
5698
httpClient: &http.Client{

‎mcp_server/internal/api/client_redirect_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func TestClientRefusesCrossOriginRedirect(t *testing.T) {
2929
}))
3030
defer origin.Close()
3131

32-
client, err := newClient(origin.URL, 5*time.Second, false, "deployment-token")
32+
client, err := newClient(origin.URL, 5*time.Second, false, "", "deployment-token")
3333
if err != nil {
3434
t.Fatal(err)
3535
}
@@ -61,7 +61,7 @@ func TestClientFollowsSameOriginRedirect(t *testing.T) {
6161
server := httptest.NewServer(mux)
6262
defer server.Close()
6363

64-
client, err := newClient(server.URL, 5*time.Second, false, "deployment-token")
64+
client, err := newClient(server.URL, 5*time.Second, false, "", "deployment-token")
6565
if err != nil {
6666
t.Fatal(err)
6767
}

0 commit comments

Comments
 (0)