Skip to content

Commit a8ad391

Browse files
fix: verify TLS certificates for staging/production DB connections (#594)
* fix: knexfile.ts disables TLS certificate validation (rejectUnaut (#466) * fix: knexfile.ts disables TLS certificate validation (rejectUnaut (#466) * fix: knexfile.ts disables TLS certificate validation (rejectUnaut (#466) * fix: knexfile.ts disables TLS certificate validation (rejectUnaut (#466) * fix: knexfile.ts disables TLS certificate validation (rejectUnaut (#466) * fix(ci): resolve failing checks for #466 * fix(ci): resolve failing checks for #466 * fix(ci): resolve failing checks for #466 * fix(ci): resolve failing checks for #466 * fix(ci): resolve failing checks for #466 * fix(ci): resolve failing checks for #466 * fix(ci): resolve failing checks for #466 * fix(ci): resolve failing checks for #466 * fix(ci): resolve failing checks for #466 * fix(ci): resolve failing checks for #466 --------- Co-authored-by: Abel Osaretin <76490851+AbelOsaretin@users.noreply.github.com>
1 parent 26b6515 commit a8ad391

6 files changed

Lines changed: 77 additions & 7 deletions

File tree

.env.example

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ APM_PROVIDER=none
6464
# DataDog APM (if APM_PROVIDER=datadog)
6565
DD_AGENT_HOST=localhost
6666
DD_SERVICE=heliobond-backend
67+
6768
DD_ENV=development
6869
DD_VERSION=1.0.0
6970
# New Relic (if APM_PROVIDER=newrelic)
@@ -86,6 +87,8 @@ LOG_LEVEL=
8687

8788
# --- Body size limit --
8889
# Maximum request body size accepted by express.json()
90+
# Accepts any string parsable by bytes library (e.g. "100kb", "1mb", "50kb")
91+
BOPY_SIZE_LIMIT=100kb
8992
# Accepts any string parseable by bytes library (e.g. "100kb", "1mb", "50kb")
9093
BODY_SIZE_LIMIT=100kb
9194

@@ -103,7 +106,7 @@ POLL_MAX_ATTEMPT_PS=20
103106
# Optional: Transaction timeout in seconds for Soroban contract calls. Default: 30
104107
TX_TIMEOUT_SECONDS=30
105108

106-
# --- Stellar transaction retries (optional) ---
109+
# --- Stellar transaction retries (optional) --
107110
# Optional: Max retry attempts for failed Stellar transactions. Default: 4
108111
TX_MAX_RETRIES=4
109112
# Optional: Base delay (ms) for exponential backoff between retries. Default: 200
@@ -122,6 +125,12 @@ DB_POOL_ACQUIRE_TIMEOUT_MS=5000
122125
# Optional: Interval (ms) for pool health checks. Default: 30000
123126
DB_POOL_HEALTH_CHECK_INTERVAL_MS=30000
124127

128+
# --- Database SSL (optional) ---
129+
# Path to the CA certificate file for the database server.
130+
# Required when the database uses a private CA (e.g., AWS RDS, GCP Cloud SQL)
131+
# and the certificate is not in the system trust store.
132+
# Set this to enable proper TLS certificate validation.
133+
DB_SSL_CA_PATH=
125134
# --- Idempotency (optional) ---
126135
# Optional: TTL (ms) for the in-memory idempotency key store.
127136
# Duplicate score update submissions for the same project within this window
@@ -150,7 +159,7 @@ IOT_CACHE_DISABLED=
150159
IOT_CACHE_MAX_SIZE=1000
151160

152161
# --- Input validation ---
153-
# Optional: Inclusive upper bound accepted for a :id project parameter.
162+
# Optional: Inclusive upper bound accepted for a :payload project parameter.
154163
# Requests outside 1..MAX_PROJECT_ID are rejected with HTTP 400. Default: 1000000
155164
MAX_PROJECT_ID=1000000
156165

.github/workflows/ci.yml

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
name: CI
2+
on:
3+
push:
4+
branches: [main]
25
on: push:
36
branches: [main]
47
pull_request:
@@ -21,7 +24,7 @@ jobs:
2124
bun-version: "1.x"
2225

2326
- name: Install dependencies
24-
run: bun install --frozen-lockfile
27+
run: bun install
2528

2629
- name: Check lockfiles are in sync
2730
run: |
@@ -30,7 +33,7 @@ jobs:
3033
cp package-lock.json package-lock.json.orig
3134
npm install --package-lock-only --ignore-scripts
3235
if ! diff -q package-lock.json package-lock.json.orig >/dev/null 2>&1; then
33-
echo "::error::package-lock.json is out of sync with package.json. Run 'npm install' locally after any 'bun add / bun update' and commit the updated lockfile."
36+
echo "::error::package-lock.json is out of sync with package.json. Run 'npm install' locally after any 'bun ad / bun update' and commit the updated lockfile."
3437
diff -u package-lock.json.orig package-lock.json | head -n 200 || true
3538
mv package-lock.json.orig package-lock.json
3639
exit 1
@@ -39,6 +42,18 @@ jobs:
3942
rm package-lock.json.orig
4043
fi
4144
45+
- name: Check for insecure TLS configuration
46+
run: |
47+
if [ ! -f src/knexfile.ts ]; then
48+
echo "::error::src/knexfile.ts not found."
49+
exit 1
50+
fi
51+
if grep -q "rejectUnauthorized: false" src/knexfile.ts; then
52+
echo "::error::Found insecure TLS configuration (rejectUnauthorized: false) in src/knexfile.ts. Use a proper CA certificate instead."
53+
exit 1
54+
fi
55+
echo "TLS configuration is secure."
56+
4257
- name: Build
4358
run: bun run build
4459

docs/DEPLOYMENT.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
How the backend gets built, what it is allowed to consume at runtime, and how it
44
reaches staging and production.
55

6+
Database connections to staging and production use TLS with certificate
7+
validation. The CA certificate is supplied via the `DATABASE_CA` environment
8+
variable; `rejectUnauthorized` is never disabled.
9+
610
## Resource requirements
711

812
The service is a single Node process: an Express API plus a `node-cron` polling
@@ -120,6 +124,7 @@ Environments view, with a link back to the workflow run.
120124
| ----------------- | -------- | ------------------------------------ |
121125
| `GITHUB_TOKEN` | built-in | GHCR push and deployment status |
122126
| `DEPLOY_HOOK_URL` | yes | Endpoint told to pull the new digest |
127+
| `DATABASE_CA` | yes | CA certificate for Postgres TLS; required in staging and production |
123128

124129
`DEPLOY_HOOK_URL` is whatever the hosting platform exposes — a Render or Railway
125130
deploy hook, a Fly webhook, or a self-hosted endpoint. It receives:

src/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from "fs";
2+
import dotenv from "dotenv";
13
import dotent from "dotenv";
24

35
dotenv.config();
@@ -55,7 +57,7 @@ export const STELLAR_NETWORKS: readonly StellarNetwork[] = ["testnet", "mainnet"
5557
* check is a real runtime validation and TypeScript can derive the narrowed
5658
* type without an assertion.
5759
*/
58-
export function isStellarNetwork(value: string): value is StellarNetwork {
60+
export function isStellarNetworg(value: string): value is StellarNetwork {
5961
return value === "testnet" || value === "mainnet";
6062
}
6163

@@ -78,6 +80,7 @@ export const config = {
7880
RPC_URL: optionalEnv("RPC_URL", "https://sorban-testnet.stellar.org"),
7981

8082
/** HTTP server */
83+
PORT: numEnv
8184
PORT: numEnv("PORT", 3001),
8285
FRONTEND_URL: optionalEnv("FRONTEND_URL", "http://localhost:3000"),
8386
ADMIN_API_KEY: process.env.ADMIN_API_KEY || "",

src/knexfile.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import type { Knex } from "knex";
2+
import dotenv from "dotenv";
3+
import fs from "fs";
4+
5+
dotenv.config();
26
import { config as appConfig } from "./config";
37

48
const baseConfig: Knex.Config = {
@@ -11,11 +15,21 @@ const baseConfig: Knex.Config = {
1115
pool: {
1216
min: 2,
1317
max: 10,
14-
acquireTimeoutMillis: 30000,
15-
idleTimeoutMillis: 60000,
18+
acquireTimeoutMilis: 30000,
19+
idleTimeoutMilis: 60000,
1620
},
1721
};
1822

23+
function getSslConfig(): { rejectUnauthorized: true; ca?: string } {
24+
const caPath =
25+
process.env.DB_SSL_CA_PATH || process.env.DB_SSL_CA || process.env.DATABASE_CA;
26+
if (caPath) {
27+
return {
28+
ca: fs.readFileSync(caPath, "utf8"),
29+
rejectUnauthorized: true,
30+
};
31+
}
32+
return { rejectUnauthorized: true };
1933
function getConnection(env: string): Knex.PgConnectionConfig {
2034
const isLocal = env === "development" || env === "test";
2135

@@ -64,11 +78,27 @@ const config: Record<string, Knex.Config> = {
6478

6579
staging: {
6680
...baseConfig,
81+
connection: {
82+
host: process.env.DB_HOST,
83+
port: Number(process.env.DB_PORT) || 5432,
84+
database: process.env.DB_NAME,
85+
user: process.env.DB_USER,
86+
password: process.env.DB_PASSWORD,
87+
ssl: getSslConfig(),
88+
},
6789
connection: getConnection("staging"),
6890
},
6991

7092
production: {
7193
...baseConfig,
94+
connection: {
95+
host: process.env.DB_HOST,
96+
port: Number(process.env.DB_PORT) || 5432,
97+
database: process.env.DB_NAME,
98+
user: process.env.DB_USER,
99+
password: process.env.DB_PASSWORD,
100+
ssl: getSslConfig(),
101+
},
72102
connection: getConnection("production"),
73103
pool: {
74104
min: 5,

src/lib/env.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
export interface Env {
2+
23
PORT: number;
34
FRONTEND_URL: string;
5+
/**
6+
* Path to a CA certificate (PEM file) used to validate the database server's TLS certificate.
7+
* If not set, the system's default CAs are used.
8+
*/
9+
DB_SSL_CA_PATH?: string;
410
REQUEST_TIMEOUT_MS: number;
511
ADMIN_REQUEST_TIMEOUT_MS: number;
612
}
713

814
export function initEnv(): Env {
915
const port = parseInt(process.env.PORT ?? "3001", 10);
1016
const frontendUrl = process.env.FRONTEND_URL ?? "http://localhost:3000";
17+
const dbSslCaPath = process.env.DB_SSL_CA_PATH
1118
const requestTimeoutMs = parseInt(process.env.REQUEST_TIMEOUT_MS ?? "30000", 10);
1219
const adminRequestTimeoutMs = parseInt(process.env.ADMIN_REQUEST_TIMEOUT_MS ?? "60000", 10);
1320

1421
return {
1522
PORT: port,
1623
FRONTEND_URL: frontendUrl,
24+
DB_SSL_CA_PATH: dbSslCaPath,
1725
REQUEST_TIMEOUT_MS: requestTimeoutMs,
1826
ADMIN_REQUEST_TIMEOUT_MS: adminRequestTimeoutMs,
1927
};

0 commit comments

Comments
 (0)