Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions availableVersions.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"production": "1.4.6",
"test": "1.4.6",
"local": "1.4.6",
"all": "1.4.6"
"production": "1.4.7",
"test": "1.4.7",
"local": "1.4.7",
"all": "1.4.7"
}
35 changes: 35 additions & 0 deletions src/api/handlers_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,41 @@ func TestWriteToFileHandler(t *testing.T) {
return ex, &fs
}

// The name is joined onto the build dir. Anything but a plain file name
// would let a caller write wherever the agent can — as root, on most devices.
t.Run("refuses a file name that leaves the build dir", func(t *testing.T) {
root := t.TempDir()
buildDir := filepath.Join(root, "build")
require.NoError(t, os.Mkdir(buildDir, 0o755))

for _, fileName := range []string{
"../escaped.tgz",
"../../escaped.tgz",
"sub/escaped.tgz",
filepath.Join(root, "escaped.tgz"),
"..",
".",
"",
} {
ex, fs := newWriteEx(t, buildDir)

res, err := ex.writeToFileHandler(context.Background(), messenger.Result{
Details: systemDetails(),
Arguments: []interface{}{"BEGIN", fileName, "dev_100_myapp", uint64(4), "t1"},
})

require.Error(t, err, fileName)
assert.Nil(t, res, fileName)
assert.Nil(t, fs.GetActiveTransfer("dev_100_myapp"), fileName)
}

_, statErr := os.Stat(filepath.Join(root, "escaped.tgz"))
assert.True(t, os.IsNotExist(statErr), "nothing was written next to the build dir")
entries, readErr := os.ReadDir(buildDir)
require.NoError(t, readErr)
assert.Empty(t, entries)
})

t.Run("writes a hex-decoded data chunk to disk", func(t *testing.T) {
buildDir := t.TempDir()
ex, fs := newWriteEx(t, buildDir)
Expand Down
57 changes: 53 additions & 4 deletions src/api/privilege_gates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import (
"io"
"net"
"os/exec"
"reagent/apps"
"reagent/common"
"reagent/container"
"reagent/errdefs"
"reagent/messenger"
"reagent/messenger/topics"
"reagent/store"
"reagent/terminal"
"reagent/testutil/fakes"
"reagent/testutil/mocks"
Expand Down Expand Up @@ -361,19 +363,23 @@ func TestRequestTerminalSessHandlerRecordsTheOwner(t *testing.T) {
}
})
docker := mocks.NewContainer(t)
docker.EXPECT().ExecCommand(mock.Anything, "app_1_prod", []string{"cat", "/etc/shells"}).Return(container.HijackedResponse{
docker.EXPECT().ExecCommand(mock.Anything, "dev_1_shellapp", []string{"cat", "/etc/shells"}).Return(container.HijackedResponse{
Conn: shells,
Reader: bufio.NewReader(strings.NewReader("/bin/sh\n/bin/bash\n")),
}, nil).Once()
docker.EXPECT().ExecAttach(mock.Anything, "app_1_prod", "/bin/bash").Return(container.HijackedResponse{
docker.EXPECT().ExecAttach(mock.Anything, "dev_1_shellapp", "/bin/bash").Return(container.HijackedResponse{
Conn: attached,
Reader: bufio.NewReader(attached),
}, nil).Once()

tm := terminal.NewTerminalManager(fakes.NewMessenger(), docker)
_, m := grantPrivilege(true)
ex := &External{Privilege: newPrivilege(testConfig(), m), TerminalManager: &tm}
tc.result.Arguments = []interface{}{map[string]interface{}{"containerName": "app_1_prod"}}
ex := &External{
Privilege: newPrivilege(testConfig(), m),
TerminalManager: &tm,
AppManager: appManagerWith(t, 1, "shellapp", common.DEV),
}
tc.result.Arguments = []interface{}{map[string]interface{}{"containerName": "dev_1_shellapp"}}

res, err := wrapDetails(ex.requestTerminalSessHandler)(context.Background(), tc.result)

Expand All @@ -387,6 +393,49 @@ func TestRequestTerminalSessHandlerRecordsTheOwner(t *testing.T) {
}
}

// appManagerWith is an AppManager whose store holds one installed app.
func appManagerWith(t *testing.T, appKey uint64, appName string, stage common.Stage) *apps.AppManager {
t.Helper()
db := newLogDB(t)
seedApp(t, db, appKey, appName, stage)
as := store.NewAppStore(db, fakes.NewMessenger())
return &apps.AppManager{AppStore: &as}
}

// DEVELOP on a device grants a shell in its apps' containers, never in any
// other container on the host — on an appliance, the platform's own database.
// The container mock is strict: a refused name that still reached Docker
// fails the test.
func TestRequestTerminalSessHandlerStaysWithinApps(t *testing.T) {
for _, name := range []string{
"ironflock-postgres-1", // a platform container on an appliance
"prod_9_otherapp", // shaped like an app, but not installed
"dev_1_otherapp", // an installed app's key, another name
"prod_1_shellapp", // the installed app, but another stage
"/dev_1_shellapp", // not the exact name the agent gives it
"dev_1_shellapp_compose-web-1", // a container it did not start as the app
} {
t.Run(name, func(t *testing.T) {
tm := terminal.NewTerminalManager(fakes.NewMessenger(), mocks.NewContainer(t))
ex := &External{
Privilege: priv(t, true),
TerminalManager: &tm,
AppManager: appManagerWith(t, 1, "shellapp", common.DEV),
}

res, err := ex.requestTerminalSessHandler(context.Background(), messenger.Result{
Details: systemDetails(),
Arguments: []interface{}{map[string]interface{}{"containerName": name}},
})

require.Error(t, err)
assert.Nil(t, res)
assert.True(t, errdefs.IsContainerNotFound(err))
assert.Empty(t, tm.ActiveSessions)
})
}
}

func TestGetCurrentIPAddressesPrivilegeGate(t *testing.T) {
t.Run("denies a caller without READ", func(t *testing.T) {
details, m := grantPrivilege(false)
Expand Down
30 changes: 27 additions & 3 deletions src/api/query_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,18 @@ func payloadOf(t *testing.T, res *messenger.InvokeResult) common.Dict {
return payload
}

// newAppLogsEx is a privileged External on a device where prod_1_logapp is
// installed — the only kind of container the log procedures may read.
func newAppLogsEx(t *testing.T) (*External, *mocks.Container) {
t.Helper()
ex, cont, db := newLogManagerEx(t, true)
seedApp(t, db, 1, "logapp", common.PROD)
return ex, cont
}

func TestQueryAppLogsHandler(t *testing.T) {
t.Run("returns a windowed slice with its provenance", func(t *testing.T) {
ex, cont, _ := newLogManagerEx(t, true)
ex, cont := newAppLogsEx(t)
expectLogs(cont, stampedBody(10))

res, err := ex.queryAppLogsHandler(context.Background(), messenger.Result{
Expand Down Expand Up @@ -84,7 +93,7 @@ func TestQueryAppLogsHandler(t *testing.T) {
"float64": float64(3),
"string": "3",
} {
ex, cont, _ := newLogManagerEx(t, true)
ex, cont := newAppLogsEx(t)
expectLogs(cont, stampedBody(10))

res, err := ex.queryAppLogsHandler(context.Background(), messenger.Result{
Expand All @@ -101,7 +110,7 @@ func TestQueryAppLogsHandler(t *testing.T) {
})

t.Run("accepts a relative window", func(t *testing.T) {
ex, cont, _ := newLogManagerEx(t, true)
ex, cont := newAppLogsEx(t)
expectLogs(cont, stampedBody(4))

res, err := ex.queryAppLogsHandler(context.Background(), messenger.Result{
Expand Down Expand Up @@ -167,6 +176,21 @@ func TestQueryAppLogsHandler(t *testing.T) {
}
})

// On an appliance the agent's host also runs the platform. A READ privilege
// on the device reaches its apps' logs, never the platform's.
t.Run("answers a container that is no app here as not found, unread", func(t *testing.T) {
ex, _ := newAppLogsEx(t)

res, err := ex.queryAppLogsHandler(context.Background(), messenger.Result{
Details: systemDetails(),
Arguments: []interface{}{map[string]interface{}{"containerName": "ironflock-postgres-1"}},
})

require.Error(t, err)
assert.Nil(t, res)
assert.True(t, errdefs.IsContainerNotFound(err))
})

t.Run("denies an unprivileged caller before reading anything", func(t *testing.T) {
details, m := grantPrivilege(false)
cont := mocks.NewContainer(t)
Expand Down
6 changes: 6 additions & 0 deletions src/api/terminal_management.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ func (ex *External) requestTerminalSessHandler(ctx context.Context, response mes
return nil, errors.New("failed to parse containerName")
}

// A shell only in an app's own container: Docker would exec into any
// container on the host, the platform's own on an appliance.
if err := ex.AppManager.AppStore.CheckAppContainer(containerName); err != nil {
return nil, err
}

termSess, err := ex.TerminalManager.RequestTerminalSession(containerName, fmt.Sprint(response.Details["caller_authid"]))
if err != nil {
return nil, err
Expand Down
8 changes: 8 additions & 0 deletions src/api/write_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"reagent/errdefs"
"reagent/filesystem"
"reagent/messenger"
Expand Down Expand Up @@ -48,6 +49,13 @@ func (ex *External) writeToFileHandler(ctx context.Context, response messenger.R
return nil, errors.New("failed to parse containerName argument")
}

// The file lands directly in the build directory. Joined onto it unchecked,
// a "../" in the caller's name wrote anywhere the agent can — as root, on
// most devices.
if fileName != filepath.Base(fileName) || !filepath.IsLocal(fileName) {
return nil, fmt.Errorf("invalid file name %q: must be a plain file name", fileName)
}

total, ok := totalArg.(uint64)
if !ok {
return nil, errors.New("failed to parse total argument")
Expand Down
7 changes: 7 additions & 0 deletions src/logging/applog.go
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,13 @@ func (lm *LogManager) GetLogHistory(containerName string) ([]string, error) {

stringLogEntries := logEntriesToString(history)

// Docker is only asked about an app's own container. Anything else keeps
// the empty answer it always got for an unknown app: a console that opens
// before the agent knows the app must still go on to subscribe.
if lm.AppStore.CheckAppContainer(containerName) != nil {
return stringLogEntries, nil
}

if containsOnlyAgentLogs || len(history) == 0 {
ctx := context.Background()
options := common.Dict{"follow": false, "stdout": true, "stderr": true, "tail": "50"}
Expand Down
4 changes: 2 additions & 2 deletions src/logging/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ type LogQueryResult struct {

// QueryLogs reads a bounded, optionally time-windowed slice of a container's logs.
func (lm *LogManager) QueryLogs(ctx context.Context, request LogQueryRequest) (LogQueryResult, error) {
if strings.TrimSpace(request.ContainerName) == "" {
return LogQueryResult{}, errors.New("container name is empty")
if err := lm.AppStore.CheckAppContainer(request.ContainerName); err != nil {
return LogQueryResult{}, err
}

tail := request.Tail
Expand Down
Loading
Loading