Skip to content

Commit 95d3cf6

Browse files
authored
Merge pull request #446 from dappnode/fix/node-security-hardening
Harden Node runtime and reject compressed JSON
2 parents 7459651 + 179dafc commit 95d3cf6

10 files changed

Lines changed: 221 additions & 24 deletions

File tree

‎.github/workflows/main.yml‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,24 @@ on:
1010

1111
jobs:
1212
docker:
13-
runs-on: ubuntu-latest
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
include:
17+
- arch: amd64
18+
runner: ubuntu-24.04
19+
- arch: arm64
20+
runner: ubuntu-24.04-arm
21+
runs-on: ${{ matrix.runner }}
1422
steps:
1523
- name: Checkout
1624
uses: actions/checkout@v7
17-
# Set up Docker Buildx for multi-arch support
1825
- name: Set up Docker Buildx
1926
uses: docker/setup-buildx-action@v4
20-
# Build multi-arch image
21-
- name: Build Docker image (multi-arch)
22-
run: |
23-
docker buildx build --platform linux/amd64,linux/arm64 .
24-
# Docker development
25-
- name: Docker development
26-
run: docker buildx build -f Dockerfile.brain.dev --platform linux/amd64,linux/arm64 .
27+
- name: Build production image
28+
run: docker buildx build --platform linux/${{ matrix.arch }} .
29+
- name: Build development image
30+
run: docker buildx build -f Dockerfile.brain.dev --platform linux/${{ matrix.arch }} .
2731
build:
2832
runs-on: ubuntu-latest
2933
steps:
@@ -33,7 +37,7 @@ jobs:
3337
- name: Setup Node.js
3438
uses: actions/setup-node@v7
3539
with:
36-
node-version: 24
40+
node-version: 24.22.1
3741

3842
- name: Enable Corepack
3943
run: corepack enable

‎.github/workflows/release.yml‎

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,23 @@ on:
99

1010
jobs:
1111
push-to-ghcr:
12-
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
include:
16+
- arch: amd64
17+
runner: ubuntu-24.04
18+
- arch: arm64
19+
runner: ubuntu-24.04-arm
20+
runs-on: ${{ matrix.runner }}
1321
permissions:
1422
contents: read
1523
packages: write
1624
steps:
1725
- name: Checkout
1826
uses: actions/checkout@v7
27+
with:
28+
ref: ${{ inputs.tag }}
1929

2030
- name: Login to GHCR
2131
uses: docker/login-action@v4
@@ -24,30 +34,64 @@ jobs:
2434
username: ${{ github.repository_owner }}
2535
password: ${{ secrets.GITHUB_TOKEN }}
2636

27-
# Set up Docker Buildx for multi-arch support
2837
- name: Set up Docker Buildx
2938
uses: docker/setup-buildx-action@v4
3039

31-
# Build and push multi-arch Docker image
32-
- name: Build and push Staking brain docker monorepo
40+
- name: Build and push native image
3341
uses: docker/build-push-action@v7
3442
with:
43+
context: .
3544
file: Dockerfile
3645
push: true
37-
platforms: linux/amd64, linux/arm64
38-
tags: ghcr.io/${{ github.repository_owner }}/staking-brain:${{ github.event.inputs.tag }}
46+
platforms: linux/${{ matrix.arch }}
47+
tags: ghcr.io/${{ github.repository_owner }}/staking-brain:build-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }}
48+
49+
publish-manifest:
50+
runs-on: ubuntu-24.04
51+
needs: push-to-ghcr
52+
permissions:
53+
contents: read
54+
packages: write
55+
steps:
56+
- name: Login to GHCR
57+
uses: docker/login-action@v4
58+
with:
59+
registry: ghcr.io
60+
username: ${{ github.repository_owner }}
61+
password: ${{ secrets.GITHUB_TOKEN }}
62+
63+
- name: Set up Docker Buildx
64+
uses: docker/setup-buildx-action@v4
65+
66+
- name: Publish multi-platform image
67+
env:
68+
IMAGE: ghcr.io/${{ github.repository_owner }}/staking-brain
69+
VERSION: ${{ inputs.tag }}
70+
BUILD: build-${{ github.run_id }}-${{ github.run_attempt }}
71+
run: |
72+
docker buildx imagetools create \
73+
--tag "$IMAGE:$VERSION" \
74+
"$IMAGE:$BUILD-amd64" \
75+
"$IMAGE:$BUILD-arm64"
3976
4077
gh-release:
4178
runs-on: ubuntu-latest
42-
needs: push-to-ghcr
79+
needs: publish-manifest
4380
permissions:
4481
contents: write
4582
steps:
4683
- name: Checkout
4784
uses: actions/checkout@v7
85+
with:
86+
ref: ${{ inputs.tag }}
4887

4988
- name: Create Release
5089
env:
5190
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5291
RELEASE_TAG: ${{ inputs.tag }}
53-
run: gh release create "$RELEASE_TAG" --title "Release v$RELEASE_TAG" --prerelease --generate-notes
92+
run: |
93+
case "$RELEASE_TAG" in
94+
*-*) release_flag=--prerelease ;;
95+
*) release_flag=--latest ;;
96+
esac
97+
gh release create "$RELEASE_TAG" --verify-tag --title "Release v$RELEASE_TAG" "$release_flag" --generate-notes

‎.nvmrc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
24.22.1

‎Dockerfile‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
ARG DOCKER_IMAGE=node:24.21.0-alpine
1+
ARG DOCKER_IMAGE=node:24.22.1-alpine3.24
22

33
# Build
44
FROM ${DOCKER_IMAGE} AS build-stage
55

6+
RUN apk add --no-cache --upgrade ca-certificates ca-certificates-bundle && update-ca-certificates
7+
68
WORKDIR /app
79
COPY package.json yarn.lock tsconfig.json .yarnrc.yml ./
810
COPY packages packages
@@ -19,6 +21,8 @@ RUN yarn install --immutable && \
1921
# Production
2022
FROM ${DOCKER_IMAGE}
2123
ENV NODE_ENV=production
24+
RUN apk add --no-cache --upgrade ca-certificates ca-certificates-bundle && update-ca-certificates
25+
2226
WORKDIR /app
2327
COPY ./packages/brain/tls ./tls
2428

‎Dockerfile.brain.dev‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
ARG DOCKER_IMAGE=node:24.21.0-alpine
1+
ARG DOCKER_IMAGE=node:24.22.1-alpine3.24
22
FROM ${DOCKER_IMAGE} AS build-stage
33

4+
RUN apk add --no-cache --upgrade ca-certificates ca-certificates-bundle && update-ca-certificates
5+
46
ENV NODE_ENV=development
57
WORKDIR /app
68

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"type": "module",
44
"private": true,
55
"engines": {
6-
"node": ">=24.11.0"
6+
"node": ">=24.22.1"
77
},
88
"workspaces": [
99
"packages/*"

‎packages/brain/src/modules/apiServers/brain/startBrainApi.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function startBrainApi({
1515
allowedOriginsFromEnv: string | string[] | null;
1616
}): http.Server {
1717
const app = express();
18-
app.use(express.json());
18+
app.use(express.json({ inflate: false }));
1919
app.use(cors({ origin: allowedOriginsFromEnv ?? allowedOrigins }));
2020

2121
app.use(createBrainValidatorsRouter({ brainDb }));

‎packages/brain/src/modules/apiServers/launchpad/startLaunchpadApi.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function startLaunchpadApi({
3232
allowedOriginsFromEnv: string | string[] | null;
3333
}): http.Server {
3434
const app = express();
35-
app.use(express.json({ limit: RPC_MAX_PAYLOAD_BYTES }));
35+
app.use(express.json({ limit: RPC_MAX_PAYLOAD_BYTES, inflate: false }));
3636
app.use(cors({ origin: allowedOriginsFromEnv ?? allowedOrigins }));
3737

3838
app.use(createKeystoresRouter({ reloadValidatorsCronTask, brainDb, network, validatorApi, signerApi, signerUrl }));

‎packages/brain/src/modules/apiServers/ui/startUiServer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export function startUiServer({
8686
origin: allowedOriginsFromEnv ?? allowedOrigins(network)
8787
})
8888
);
89-
app.use(express.json());
89+
app.use(express.json({ inflate: false }));
9090
app.use(express.static(uiBuildPath));
9191
// path-to-regexp (used by express >=5.0.0) has a breaking change
9292
// where it does not allow to use wildcard among other characters
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { expect } from "chai";
2+
import sinon from "sinon";
3+
import fs from "node:fs";
4+
import os from "node:os";
5+
import path from "node:path";
6+
import http from "node:http";
7+
import type { AddressInfo } from "node:net";
8+
import { once } from "node:events";
9+
import { brotliCompressSync } from "node:zlib";
10+
import { ConsensusClient, ExecutionClient, Network } from "@stakingbrain/common";
11+
import { startBrainApi } from "../../../src/modules/apiServers/brain/startBrainApi.js";
12+
import { startLaunchpadApi } from "../../../src/modules/apiServers/launchpad/startLaunchpadApi.js";
13+
import { startUiServer } from "../../../src/modules/apiServers/ui/startUiServer.js";
14+
import {
15+
BeaconchainApi,
16+
BlockExplorerApi,
17+
PostgresClient,
18+
ValidatorApi,
19+
Web3SignerApi
20+
} from "../../../src/modules/apiClients/index.js";
21+
import { BrainDataBase } from "../../../src/modules/db/index.js";
22+
import { CronJob } from "../../../src/modules/cron/cron.js";
23+
import { params } from "../../../src/params.js";
24+
25+
function postJson(server: http.Server, body: Buffer, encoding?: string): Promise<number> {
26+
const address = server.address() as AddressInfo;
27+
return new Promise((resolve, reject) => {
28+
const request = http.request(
29+
{
30+
hostname: "127.0.0.1",
31+
port: address.port,
32+
path: "/",
33+
method: "POST",
34+
headers: {
35+
"Content-Type": "application/json",
36+
"Content-Length": body.length,
37+
...(encoding ? { "Content-Encoding": encoding } : {})
38+
}
39+
},
40+
(response) => {
41+
response.resume();
42+
response.on("end", () => resolve(response.statusCode!));
43+
response.on("error", reject);
44+
}
45+
);
46+
request.on("error", reject);
47+
request.end(body);
48+
});
49+
}
50+
51+
describe("JSON request content encoding", () => {
52+
const servers = new Map<string, http.Server>();
53+
let directory: string;
54+
55+
before(async () => {
56+
directory = fs.mkdtempSync(path.join(os.tmpdir(), "stakingbrain-json-encoding-"));
57+
fs.writeFileSync(path.join(directory, "index.html"), "<html><head></head><body></body></html>");
58+
for (const port of ["brainPort", "launchpadPort", "uiPort"] as const) sinon.stub(params, port).value(0);
59+
const brainDb = sinon.createStubInstance(BrainDataBase);
60+
const validatorApi = sinon.createStubInstance(ValidatorApi);
61+
const signerApi = sinon.createStubInstance(Web3SignerApi);
62+
const beaconchainApi = sinon.createStubInstance(BeaconchainApi);
63+
const reloadValidatorsCronTask = sinon.createStubInstance(CronJob);
64+
const signerUrl = "http://127.0.0.1:1";
65+
const allowedOriginsFromEnv = null;
66+
servers.set("Brain", startBrainApi({ brainDb, allowedOriginsFromEnv }));
67+
servers.set(
68+
"Launchpad",
69+
startLaunchpadApi({
70+
brainDb,
71+
validatorApi,
72+
signerApi,
73+
beaconchainApi,
74+
reloadValidatorsCronTask,
75+
signerUrl,
76+
network: Network.Hoodi,
77+
allowedOriginsFromEnv
78+
})
79+
);
80+
servers.set(
81+
"UI",
82+
startUiServer({
83+
brainDb,
84+
validatorApi,
85+
signerApi,
86+
beaconchainApi,
87+
reloadValidatorsCronTask,
88+
blockExplorerApi: sinon.createStubInstance(BlockExplorerApi),
89+
postgresClient: sinon.createStubInstance(PostgresClient),
90+
uiBuildPath: directory,
91+
allowedOriginsFromEnv,
92+
brainConfig: {
93+
chain: {
94+
network: Network.Hoodi,
95+
executionClient: ExecutionClient.Geth,
96+
consensusClient: ConsensusClient.Teku,
97+
isMevBoostSet: false,
98+
minGenesisTime: 0,
99+
secondsPerSlot: 12,
100+
slotsPerEpoch: 32
101+
},
102+
apis: {
103+
signerUrl,
104+
validatorUrl: signerUrl,
105+
beaconchainUrl: signerUrl,
106+
executionClientUrl: signerUrl,
107+
prometheusUrl: signerUrl,
108+
dappmanagerUrl: signerUrl,
109+
blockExplorerUrl: signerUrl,
110+
postgresUrl: signerUrl,
111+
token: "",
112+
tlsCert: null,
113+
host: "localhost",
114+
cors: null
115+
}
116+
}
117+
})
118+
);
119+
await Promise.all([...servers.values()].map((server) => once(server, "listening")));
120+
});
121+
122+
after(async () => {
123+
await Promise.all(
124+
[...servers.values()].map(
125+
(server) => new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
126+
)
127+
);
128+
sinon.restore();
129+
fs.rmSync(directory, { recursive: true, force: true });
130+
});
131+
132+
for (const name of ["Brain", "Launchpad", "UI"]) {
133+
it(`${name} rejects Brotli-compressed JSON with HTTP 415`, async () => {
134+
const body = brotliCompressSync(Buffer.from('{"probe":true}'));
135+
expect(await postJson(servers.get(name)!, body, "br")).to.equal(415);
136+
});
137+
138+
it(`${name} lets identity JSON reach routing`, async () => {
139+
expect(await postJson(servers.get(name)!, Buffer.from('{"probe":true}'), "identity")).to.equal(404);
140+
});
141+
}
142+
});

0 commit comments

Comments
 (0)