Skip to content

Commit 8edf556

Browse files
author
Mahmood Ali
authored
Merge pull request #7487 from hashicorp/b-xss-oss
agent: prevent XSS by controlling Content-Type
2 parents 5062622 + 9c59f77 commit 8edf556

File tree

7 files changed

+612
-2
lines changed

7 files changed

+612
-2
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ BUG FIXES:
3939
* client: Fixed a panic when running in Debian with `/etc/debian_version` is empty [[GH-7350](https://github.com/hashicorp/nomad/issues/7350)]
4040
* client: Fixed a bug where a multi-task allocation maybe considered healthy despite a task restarting [[GH-7383](https://github.com/hashicorp/nomad/issues/7383)]
4141

42+
## 0.10.5 (March 24, 2020)
43+
44+
SECURITY:
45+
46+
* server: Override content-type headers for unsafe content. CVE-TBD [[GH-7468](https://github.com/hashicorp/nomad/issues/7468)]
47+
4248
## 0.10.4 (February 19, 2020)
4349

4450
FEATURES:

command/agent/agent_endpoint.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,14 @@ func (s *HTTPServer) AgentMonitor(resp http.ResponseWriter, req *http.Request) (
201201
return nil, CodedError(400, "Cannot target node and server simultaneously")
202202
}
203203

204+
// Force the Content-Type to avoid Go's http.ResponseWriter from
205+
// detecting an incorrect or unsafe one.
206+
if plainText {
207+
resp.Header().Set("Content-Type", "text/plain")
208+
} else {
209+
resp.Header().Set("Content-Type", "application/json")
210+
}
211+
204212
s.parse(resp, req, &args.QueryOptions.Region, &args.QueryOptions)
205213

206214
// Make the RPC

command/agent/agent_endpoint_test.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ package agent
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io/ioutil"
89
"net"
910
"net/http"
1011
"net/http/httptest"
1112
"net/url"
13+
"os"
1214
"strings"
15+
"sync"
16+
"syscall"
1317
"testing"
1418
"time"
1519

@@ -1233,3 +1237,229 @@ func TestHTTP_AgentHealth_BadClient(t *testing.T) {
12331237
}
12341238
})
12351239
}
1240+
1241+
var (
1242+
errorPipe = &net.OpError{
1243+
Op: "write",
1244+
Net: "tcp",
1245+
Source: &net.TCPAddr{},
1246+
Addr: &net.TCPAddr{},
1247+
Err: &os.SyscallError{
1248+
Syscall: "write",
1249+
Err: syscall.EPIPE,
1250+
},
1251+
}
1252+
)
1253+
1254+
// fakeRW is a fake response writer to ease polling streaming responses in a
1255+
// data-race-free way.
1256+
type fakeRW struct {
1257+
Code int
1258+
HeaderMap http.Header
1259+
buf *bytes.Buffer
1260+
closed bool
1261+
mu sync.Mutex
1262+
1263+
// Written is ticked whenever a Write occurs and on WriteHeaders if it
1264+
// is explicitly called
1265+
Written chan int
1266+
1267+
// ClosedErr is the error Write will return once the writer is closed.
1268+
// Defaults to EPIPE. Must not be mutated concurrently with writes.
1269+
ClosedErr error
1270+
}
1271+
1272+
// Header is for setting headers before writing a response. Tests should check
1273+
// the HeaderMap field directly.
1274+
func (f *fakeRW) Header() http.Header {
1275+
f.mu.Lock()
1276+
defer f.mu.Unlock()
1277+
1278+
if f.Code != 0 {
1279+
panic("cannot set headers after WriteHeader has been called")
1280+
}
1281+
1282+
return f.HeaderMap
1283+
}
1284+
1285+
func (f *fakeRW) Write(p []byte) (int, error) {
1286+
f.mu.Lock()
1287+
defer f.mu.Unlock()
1288+
1289+
if f.closed {
1290+
// Mimic an EPIPE error
1291+
return 0, f.ClosedErr
1292+
}
1293+
1294+
if f.Code == 0 {
1295+
f.Code = 200
1296+
}
1297+
1298+
n, err := f.buf.Write(p)
1299+
select {
1300+
case f.Written <- 1:
1301+
default:
1302+
}
1303+
return n, err
1304+
}
1305+
1306+
// WriteHeader sets Code and FinalHeaders
1307+
func (f *fakeRW) WriteHeader(statusCode int) {
1308+
f.mu.Lock()
1309+
defer f.mu.Unlock()
1310+
1311+
if f.Code != 0 {
1312+
panic("cannot call WriteHeader more than once")
1313+
}
1314+
1315+
f.Code = statusCode
1316+
select {
1317+
case f.Written <- 1:
1318+
default:
1319+
}
1320+
}
1321+
1322+
// Bytes returns the body bytes written to the buffer. Safe for calling
1323+
// concurrent with writes.
1324+
func (f *fakeRW) Bytes() []byte {
1325+
f.mu.Lock()
1326+
defer f.mu.Unlock()
1327+
1328+
return f.buf.Bytes()
1329+
}
1330+
1331+
// Close the writer causing an EPIPE error on future writes. Safe to call
1332+
// concurrently with other methods. Safe to call more than once.
1333+
func (f *fakeRW) Close() {
1334+
f.mu.Lock()
1335+
defer f.mu.Unlock()
1336+
f.closed = true
1337+
}
1338+
1339+
func NewFakeRW() *fakeRW {
1340+
return &fakeRW{
1341+
HeaderMap: make(map[string][]string),
1342+
buf: &bytes.Buffer{},
1343+
Written: make(chan int, 1),
1344+
ClosedErr: errorPipe,
1345+
}
1346+
}
1347+
1348+
// TestHTTP_XSS_Monitor asserts /v1/agent/monitor is safe against XSS attacks
1349+
// even when log output contains HTML+Javascript.
1350+
func TestHTTP_XSS_Monitor(t *testing.T) {
1351+
t.Parallel()
1352+
1353+
cases := []struct {
1354+
Name string
1355+
Logline string
1356+
JSON bool
1357+
}{
1358+
{
1359+
Name: "Plain",
1360+
Logline: "--TEST 123--",
1361+
JSON: false,
1362+
},
1363+
{
1364+
Name: "JSON",
1365+
Logline: "--TEST 123--",
1366+
JSON: true,
1367+
},
1368+
{
1369+
Name: "XSSPlain",
1370+
Logline: "<script>alert(document.domain);</script>",
1371+
JSON: false,
1372+
},
1373+
{
1374+
Name: "XSSJson",
1375+
Logline: "<script>alert(document.domain);</script>",
1376+
JSON: true,
1377+
},
1378+
}
1379+
1380+
for i := range cases {
1381+
tc := cases[i]
1382+
t.Run(tc.Name, func(t *testing.T) {
1383+
t.Parallel()
1384+
s := makeHTTPServer(t, nil)
1385+
defer s.Shutdown()
1386+
1387+
path := fmt.Sprintf("%s/v1/agent/monitor?error_level=error&plain=%t", s.HTTPAddr(), !tc.JSON)
1388+
req, err := http.NewRequest("GET", path, nil)
1389+
require.NoError(t, err)
1390+
resp := NewFakeRW()
1391+
closedErr := errors.New("sentinel error")
1392+
resp.ClosedErr = closedErr
1393+
defer resp.Close()
1394+
1395+
errCh := make(chan error, 1)
1396+
go func() {
1397+
_, err := s.Server.AgentMonitor(resp, req)
1398+
errCh <- err
1399+
}()
1400+
1401+
deadline := time.After(3 * time.Second)
1402+
1403+
OUTER:
1404+
for {
1405+
// Log a needle and look for it in the response haystack
1406+
s.Server.logger.Error(tc.Logline)
1407+
1408+
select {
1409+
case <-time.After(30 * time.Millisecond):
1410+
// Give AgentMonitor handler goroutine time to start
1411+
case <-resp.Written:
1412+
// Something was written, check it
1413+
case <-deadline:
1414+
t.Fatalf("timed out waiting for expected log line; body:\n%s", string(resp.Bytes()))
1415+
case err := <-errCh:
1416+
t.Fatalf("AgentMonitor exited unexpectedly: err=%v", err)
1417+
}
1418+
1419+
if !tc.JSON {
1420+
if bytes.Contains(resp.Bytes(), []byte(tc.Logline)) {
1421+
// Found needle!
1422+
break
1423+
} else {
1424+
// Try again
1425+
continue
1426+
}
1427+
}
1428+
1429+
// Decode JSON
1430+
r := bytes.NewReader(resp.Bytes())
1431+
dec := json.NewDecoder(r)
1432+
for {
1433+
data := struct{ Data []byte }{}
1434+
if err := dec.Decode(&data); err != nil {
1435+
// Probably a partial write, continue
1436+
continue OUTER
1437+
}
1438+
1439+
if bytes.Contains(data.Data, []byte(tc.Logline)) {
1440+
// Found needle!
1441+
break OUTER
1442+
}
1443+
}
1444+
1445+
}
1446+
1447+
// Assert default logs are application/json
1448+
ct := "text/plain"
1449+
if tc.JSON {
1450+
ct = "application/json"
1451+
}
1452+
require.Equal(t, []string{ct}, resp.HeaderMap.Values("Content-Type"))
1453+
1454+
// Close response writer and log to make AgentMonitor exit
1455+
resp.Close()
1456+
s.Server.logger.Error("log again to force a write that detects the closed connection")
1457+
select {
1458+
case err := <-errCh:
1459+
require.EqualError(t, closedErr, err.Error())
1460+
case <-deadline:
1461+
t.Fatalf("timed out waiting for closing error from handler")
1462+
}
1463+
})
1464+
}
1465+
}

command/agent/fs_endpoint.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ func (s *HTTPServer) FsRequest(resp http.ResponseWriter, req *http.Request) (int
3333
case strings.HasPrefix(path, "stat/"):
3434
return s.FileStatRequest(resp, req)
3535
case strings.HasPrefix(path, "readat/"):
36-
return s.FileReadAtRequest(resp, req)
36+
return s.wrapUntrustedContent(s.FileReadAtRequest)(resp, req)
3737
case strings.HasPrefix(path, "cat/"):
38-
return s.FileCatRequest(resp, req)
38+
return s.wrapUntrustedContent(s.FileCatRequest)(resp, req)
3939
case strings.HasPrefix(path, "stream/"):
4040
return s.Stream(resp, req)
4141
case strings.HasPrefix(path, "logs/"):
42+
// Logs are *trusted* content because the endpoint
43+
// explicitly sets the Content-Type to text/plain or
44+
// application/json depending on the value of the ?plain=
45+
// parameter.
4246
return s.Logs(resp, req)
4347
default:
4448
return nil, CodedError(404, ErrInvalidMethod)
@@ -320,6 +324,14 @@ func (s *HTTPServer) Logs(resp http.ResponseWriter, req *http.Request) (interfac
320324
}
321325
s.parse(resp, req, &fsReq.QueryOptions.Region, &fsReq.QueryOptions)
322326

327+
// Force the Content-Type to avoid Go's http.ResponseWriter from
328+
// detecting an incorrect or unsafe one.
329+
if plain {
330+
resp.Header().Set("Content-Type", "text/plain")
331+
} else {
332+
resp.Header().Set("Content-Type", "application/json")
333+
}
334+
323335
// Make the request
324336
return s.fsStreamImpl(resp, req, "FileSystem.Logs", fsReq, fsReq.AllocID)
325337
}

0 commit comments

Comments
 (0)