Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ OrcaCD is a simple GitOps tool for Docker. It allows you to deploy your applicat

You can get started with OrcaCD by following the guide in the [documentation](https://orcacd.dev).

### Restricted deployment directories

The agent checks deployment paths for symlinks before deploying. A container can create a symlink in a writable bind mount and use it as a mount source on a later deployment to access paths outside its application directory. If directory permissions prevent the agent from inspecting a path, the deployment is rejected.

Prefer granting the agent search permission on the affected parent directories. If that is impractical, you can uncomment the `cap_add` block for `DAC_READ_SEARCH` in the agent's Compose configuration and recreate the agent container. This optional Linux capability bypasses file read and directory read/search permissions for **all files accessible to the agent**, not just the deployments directory. It does not bypass write permissions. It is disabled by default; symlink checks remain enabled.

## Contribute

You're very welcome to contribute to OrcaCD! Please follow the [contribution guide](https://github.com/OrcaCD/orca-cd/blob/main/CONTRIBUTING.md) to get started. If you wish to contribute to the documentation head over to the [repository](https://github.com/OrcaCD/docs).
3 changes: 3 additions & 0 deletions backend/internal/agent/docker/deployment_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ func deploymentPathViolation(path, deploymentsDir, applicationDir string) string
}
link, err := firstSymlink(deploymentsDir, path)
if err != nil {
if errors.Is(err, os.ErrPermission) {
return fmt.Sprintf("could not be checked for symlinks: %v; allow the agent to search the parent directories, or opt in to the agent's DAC_READ_SEARCH capability (bypasses read/search permissions for all files accessible to the agent)", err)
}
return fmt.Sprintf("could not be checked for symlinks: %v", err)
}
if link != "" {
Expand Down
31 changes: 31 additions & 0 deletions backend/internal/agent/docker/deployment_paths_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package docker

import (
"errors"
"os"
"path/filepath"
"strings"
Expand All @@ -9,6 +10,36 @@ import (
composetypes "github.com/compose-spec/compose-go/v2/types"
)

func TestDeploymentPaths_RejectsInaccessiblePathsWithGuidance(t *testing.T) {
rules := setupDeploymentDirs(t)
parent := filepath.Join(rules.applicationDir, "data")
source := filepath.Join(parent, "private")
if err := os.Mkdir(source, 0o750); err != nil {
t.Fatal(err)
}
if err := os.Chmod(parent, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
//nolint:gosec // Restore directory traversal so t.TempDir can clean up.
if err := os.Chmod(parent, 0o750); err != nil {
t.Error(err)
}
})
if _, err := os.Lstat(source); !errors.Is(err, os.ErrPermission) {
if err == nil {
t.Skip("process can bypass directory permissions")
}
t.Fatal(err)
}

err := checkDeployPolicy(bindMountProject(source), rules)
requireErrorContains(t, err, "could not be checked for symlinks")
requireErrorContains(t, err, "allow the agent to search the parent directories")
requireErrorContains(t, err, "opt in to the agent's DAC_READ_SEARCH capability")
requireErrorContains(t, err, "bypasses read/search permissions for all files accessible to the agent")
}

func setupDeploymentDirs(t *testing.T) pathRules {
t.Helper()
deploymentsDir := t.TempDir()
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ services:
- no-new-privileges:true
cap_drop:
- ALL
# Optional: inspect deployment paths inside directories restricted by application containers.
# This bypasses read/search permissions for all files accessible to the agent.
# cap_add:
# - DAC_READ_SEARCH
use_api_socket: true # Allows container to access Docker API on host
volumes:
- ./deployments:/deployments
Expand Down
7 changes: 7 additions & 0 deletions e2e/docker-compose.e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ services:
profiles:
- agent
container_name: orca-agent-e2e-test
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
# Exercise the optional capability for restricted deployment directories.
- DAC_READ_SEARCH
use_api_socket: true
volumes:
- ./data/agent/deployments:/deployments
Expand Down
3 changes: 3 additions & 0 deletions e2e/tests/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ test.describe("Agent setup", () => {
const compose = await setup.locator("pre").innerText();
expect(compose).toContain(`HUB_URL: ${APP_URL}`);
expect(compose).toMatch(/image: ghcr\.io\/orcacd\/agent:\S+/);
expect(compose).toMatch(/cap_drop:\s+- ALL/);
expect(compose).not.toMatch(/^\s*cap_add:/m);
expect(compose).toMatch(/# cap_add:\s+#\s+- DAC_READ_SEARCH/);
authToken = compose.match(/AUTH_TOKEN: (\S+)/)?.[1] ?? "";
expect(authToken).not.toBe("");
await setup.getByRole("button", { name: "Close" }).click();
Expand Down
34 changes: 32 additions & 2 deletions e2e/tests/applications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
pullImage,
removeApplicationContainers,
removeVolumes,
runWithAgentVolumes,
startContainer,
stopContainer,
} from "./helpers/docker.ts";
Expand Down Expand Up @@ -130,7 +131,7 @@ const SAFE_VOLUME = `${SAFE_PROJECT}_data`;
const safeCompose = policyComposeFile(
` volumes:
- data:/named
- ./bound:/bound
- ./bound/data:/bound
`,
`volumes:
data:
Expand Down Expand Up @@ -944,7 +945,7 @@ test.describe("Compose security policy", () => {
const bind = mounts.find((mount) => mount.Destination === "/bound");
expect(bind?.Type).toBe("bind");
// The agent translates the path inside its container to the one on the Docker host
expect(bind?.Source).toMatch(new RegExp(`/${SAFE_PROJECT}/bound$`));
expect(bind?.Source).toMatch(new RegExp(`/${SAFE_PROJECT}/bound/data$`));

await expect
.poll(() => execInContainer(container.id, ["cat", "/named/file"]), { timeout: 15_000 })
Expand All @@ -955,6 +956,35 @@ test.describe("Compose security policy", () => {
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ type: "deployment", status: "succeeded" });
});

test("redeploys through a directory restricted to an application user", async ({ page }) => {
const id = policyAppIds.get(SAFE_NAME)!;
const parent = `/deployments/${SAFE_PROJECT}/bound`;
const restrict = runWithAgentVolumes([
"sh",
"-ec",
`chown 1234:1234 ${parent}; chmod 700 ${parent}`,
]);
expect(restrict.status, restrict.stderr).toBe(0);
try {
const before = (await applicationEvents(page, id)).length;
expect((await api(page, "POST", `/applications/${id}/deploy`)).status).toBe(202);
await expect
.poll(async () => (await applicationEvents(page, id)).length, { timeout: 60_000 })
.toBe(before + 1);
await expect
.poll(async () => (await applicationEvents(page, id))[0].status, { timeout: 60_000 })
.toBe("succeeded");
expect((await getApplication(page, id)).lastSyncError).toBeUndefined();
} finally {
const restore = runWithAgentVolumes([
"sh",
"-ec",
`chmod 755 ${parent}; chown 0:0 ${parent}`,
]);
expect(restore.status, restore.stderr).toBe(0);
}
});
});

test.describe("Deployment notifications", () => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/helpers/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function inspectContainer(id: string) {

// The deployments directory is owned by root on Linux hosts and the agent image has no shell,
// so a throwaway container with the agent's volumes reads it
function runWithAgentVolumes(command: string[]) {
export function runWithAgentVolumes(command: string[]) {
return spawnSync(
"docker",
["run", "--rm", "--volumes-from", AGENT_CONTAINER, BUSYBOX_IMAGE, ...command],
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/dialogs/agent-setup-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export function buildAgentComposeFile({
- no-new-privileges:true
cap_drop:
- ALL
# Optional: inspect deployment paths inside directories restricted by application containers.
# This bypasses read/search permissions for all files accessible to the agent.
# cap_add:
# - DAC_READ_SEARCH
volumes:
- ./deployments:/deployments
`;
Expand Down
Loading