Skip to content

Commit d249a3d

Browse files
authored
fix(calm-hub): security hardening for ec2 stack and read-only deployment (#2798)
* fix(calm-hub): harden ec2 compose stack and pin watchtower Watchtower holds the Docker socket, so it must not auto-track a mutable latest tag. * fix(calm-hub): add nosniff and referrer-policy security headers * fix(calm-hub): add authenticated path policy for /api/calm in secure profile
1 parent e7d692b commit d249a3d

6 files changed

Lines changed: 92 additions & 17 deletions

File tree

calm-hub/ec2/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Runs [`finos/calm-hub:latest-read-only-native`](https://hub.docker.com/r/finos/calm-hub) — the
44
self-contained CALM Hub image (NitriteDB baked in, no external database, no auth) — on a single
5-
EC2 host, auto-updated by [Watchtower](https://github.com/nickfedor/watchtower) whenever a new
5+
EC2 host, auto-updated by [Watchtower](https://github.com/nicholas-fedor/watchtower) whenever a new
66
image is published to Docker Hub.
77

88
Uses the [`nickfedor/watchtower`](https://hub.docker.com/r/nickfedor/watchtower) image — the
@@ -11,6 +11,11 @@ archived in December 2025. The original image's frozen Docker client can no long
1111
current Docker daemons (it negotiates down to an API version below the daemon's minimum), so do
1212
not switch back to `containrrr/watchtower`.
1313

14+
Watchtower is **pinned to a release tag** in `docker-compose.yml` (it holds the Docker socket, so
15+
it must not auto-track a mutable `latest` tag; Watchtower never updates itself). To bump it, pick
16+
a release from [nicholas-fedor/watchtower/releases](https://github.com/nicholas-fedor/watchtower/releases),
17+
update the `image:` tag, and run `sudo docker compose up -d`.
18+
1419
This folder supersedes the previous on-box `setup.sh` / `restart.sh` scripts, which lived only on
1520
the instance and had to be run by hand.
1621

@@ -69,6 +74,10 @@ limits *which containers Watchtower will act on*, not what the socket itself gra
6974
sandbox Watchtower's own privileges. Only deploy this stack on a dedicated, otherwise-locked-down
7075
box — don't run other untrusted workloads alongside it.
7176

77+
Both containers run with `no-new-privileges` and all Linux capabilities dropped, and `calm-hub`
78+
runs on a read-only root filesystem (tmpfs `/tmp` only) — the app is a native binary serving a
79+
baked-in read-only database, so nothing needs to write to disk.
80+
7281
**This swap is not zero-downtime.** The old container stops before the new one starts, causing a
7382
brief (<1s) gap in availability. Maintainers have accepted this tradeoff rather than adding a
7483
reverse proxy for blue-green swaps.

calm-hub/ec2/docker-compose.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ services:
2222
# Opts this container into Watchtower; --label-enable means Watchtower
2323
# ignores every other container on the box.
2424
- "com.centurylinklabs.watchtower.enable=true"
25+
# Hardening: the app is a native binary reading a baked-in, read-only
26+
# NitriteDB — it needs no capabilities, no privilege escalation, and no
27+
# writable filesystem beyond /tmp (Vert.x cache).
28+
security_opt:
29+
- "no-new-privileges:true"
30+
cap_drop:
31+
- ALL
32+
read_only: true
33+
tmpfs:
34+
- /tmp
2535
# No environment needed: the read-only-native image bakes in
2636
# QUARKUS_PROFILE=standalone, CALM_READONLY=true, the standalone data
2737
# directory, and disabled auth.
@@ -33,9 +43,23 @@ services:
3343
watchtower:
3444
# containrrr/watchtower was archived (Dec 2025) — nickfedor/watchtower is the
3545
# actively maintained, API-compatible continuation of the same project.
36-
image: nickfedor/watchtower:latest
46+
#
47+
# Pinned to a release tag: this container holds the Docker socket
48+
# (root-equivalent), so it must not auto-track a mutable `latest` tag.
49+
# Watchtower never updates itself (--label-enable and it isn't labelled);
50+
# bump the pin manually — releases at
51+
# https://github.com/nicholas-fedor/watchtower/releases — then
52+
# `sudo docker compose up -d`.
53+
image: nickfedor/watchtower:1.19.0
3754
container_name: watchtower
3855
restart: unless-stopped
56+
# Hardening: watchtower only talks to the Docker API over the mounted
57+
# socket — it needs no capabilities and no privilege escalation. (The
58+
# socket itself still grants root-equivalent control; see README.md.)
59+
security_opt:
60+
- "no-new-privileges:true"
61+
cap_drop:
62+
- ALL
3963
volumes:
4064
- /var/run/docker.sock:/var/run/docker.sock
4165
command:

calm-hub/ec2/install.sh

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,25 @@ if ! sudo docker compose version >/dev/null 2>&1; then
4141
ASSET="docker-compose-linux-${ARCH}"
4242
RELEASE_URL="https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}"
4343

44+
# Checksums vendored from the release's published .sha256 assets. Fetching
45+
# the checksum from the same host as the binary at install time only proves
46+
# the download wasn't corrupted; a vendored hash also detects a tampered
47+
# release. When bumping COMPOSE_VERSION, update both hashes from the new
48+
# release's .sha256 files.
49+
case "${ARCH}" in
50+
x86_64) EXPECTED_SHA="ed1917fb54db184192ea9d0717bcd59e3662ea79db48bff36d3475516c480a6b" ;;
51+
aarch64) EXPECTED_SHA="0c4591cf3b1ed039adcd803dbbeddf757375fc08c11245b0154135f838495a2f" ;;
52+
*)
53+
echo "!! No vendored Docker Compose checksum for architecture '${ARCH}' — aborting" >&2
54+
exit 1
55+
;;
56+
esac
57+
4458
TMP_COMPOSE="$(mktemp)"
4559
curl -fsSL "${RELEASE_URL}/${ASSET}" -o "${TMP_COMPOSE}"
4660

47-
# Verify against Docker's published checksum before installing a root-owned
61+
# Verify against the vendored checksum before installing a root-owned
4862
# binary — protects against a corrupted or tampered download.
49-
EXPECTED_SHA="$(curl -fsSL "${RELEASE_URL}/${ASSET}.sha256" | awk '{print $1}')"
5063
ACTUAL_SHA="$(sha256sum "${TMP_COMPOSE}" | awk '{print $1}')"
5164
if [ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]; then
5265
echo "!! Docker Compose checksum mismatch (expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}) — aborting" >&2

calm-hub/src/main/resources/application-secure.properties

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ quarkus.http.ssl.certificate.files=cert.pem
77
quarkus.http.auth.permission.secured.paths=/calm/*
88
quarkus.http.auth.permission.secured.policy=authenticated
99

10+
# Safety net for the namespace-scoped API: every method under /api/calm/* is
11+
# individually annotated (@PermissionsAllowed / @Authenticated), but this
12+
# coarse policy guarantees a future endpoint added without an annotation is
13+
# still rejected rather than silently unauthenticated.
14+
quarkus.http.auth.permission.api.paths=/api/calm/*
15+
quarkus.http.auth.permission.api.policy=authenticated
16+
1017
quarkus.http.auth.permission.mcp-tools.paths=/mcp/*
1118
quarkus.http.auth.permission.mcp-tools.policy=authenticated
1219

calm-hub/src/main/resources/application.properties

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,15 @@ calm.auth.enabled=true
4949

5050
#quarkus.http.port=8081
5151

52-
# Security response headers — applied to all routes
52+
# Security response headers — applied to all routes.
53+
# nosniff stops browsers content-type-sniffing error bodies into HTML (the
54+
# backstop behind the STRICT_SANITIZATION_POLICY discipline on echoed input);
55+
# no-referrer keeps hub URLs out of outbound requests from Swagger UI links.
5356
quarkus.http.filter.security.matches=/.*
54-
quarkus.http.filter.security.methods=GET,POST,PUT,DELETE,PATCH
57+
quarkus.http.filter.security.methods=GET,HEAD,POST,PUT,DELETE,PATCH
5558
quarkus.http.filter.security.header."X-Frame-Options"=DENY
59+
quarkus.http.filter.security.header."X-Content-Type-Options"=nosniff
60+
quarkus.http.filter.security.header."Referrer-Policy"=no-referrer
5661

5762
# MCP Server Configuration (experimental)
5863
# Gates MCP tool invocations. When false (the default), every @Tool method

calm-hub/src/test/java/org/finos/calm/security/TestSecurityResponseHeadersShould.java

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.quarkus.test.InjectMock;
44
import io.quarkus.test.junit.QuarkusTest;
55
import io.quarkus.test.security.TestSecurity;
6+
import io.restassured.response.ValidatableResponse;
67
import org.finos.calm.store.NamespaceStore;
78
import org.finos.calm.store.UserAccessStore;
89
import org.junit.jupiter.api.Test;
@@ -21,31 +22,47 @@
2122
public class TestSecurityResponseHeadersShould {
2223

2324
@InjectMock
24-
NamespaceStore namespaceStore;
25+
NamespaceStore mockNamespaceStore;
2526

2627
@InjectMock
27-
UserAccessStore userAccessStore;
28+
UserAccessStore mockUserAccessStore;
29+
30+
private static void assertSecurityHeaders(ValidatableResponse response) {
31+
response
32+
.header("X-Frame-Options", equalTo("DENY"))
33+
.header("X-Content-Type-Options", equalTo("nosniff"))
34+
.header("Referrer-Policy", equalTo("no-referrer"));
35+
}
2836

2937
@Test
30-
void return_x_frame_options_deny_on_get_request() {
31-
when(namespaceStore.getNamespaces()).thenReturn(new ArrayList<>());
38+
void return_security_headers_on_get_request() {
39+
when(mockNamespaceStore.getNamespaces()).thenReturn(new ArrayList<>());
3240

33-
given()
41+
assertSecurityHeaders(given()
3442
.when()
3543
.get("/api/calm/namespaces")
3644
.then()
37-
.statusCode(200)
38-
.header("X-Frame-Options", equalTo("DENY"));
45+
.statusCode(200));
3946
}
4047

4148
@Test
42-
void return_x_frame_options_deny_on_post_request() {
43-
given()
49+
void return_security_headers_on_head_request() {
50+
when(mockNamespaceStore.getNamespaces()).thenReturn(new ArrayList<>());
51+
52+
assertSecurityHeaders(given()
53+
.when()
54+
.head("/api/calm/namespaces")
55+
.then()
56+
.statusCode(200));
57+
}
58+
59+
@Test
60+
void return_security_headers_on_post_request() {
61+
assertSecurityHeaders(given()
4462
.contentType("application/json")
4563
.body("{\"name\":\"test\",\"description\":\"test\"}")
4664
.when()
4765
.post("/api/calm/namespaces")
48-
.then()
49-
.header("X-Frame-Options", equalTo("DENY"));
66+
.then());
5067
}
5168
}

0 commit comments

Comments
 (0)