Improve handling of external proxy configurations and session security

Introduces `TRUST_PROXY_HTTPS` environment variable and updates session cookie security logic in `app.ts`. Modifies `README.md` and `docker-compose.yml` for clarity on proxy configurations. Updates `seed.ts` to conditionally seed demo meetings based on `SEED_DEMO_MEETINGS` environment variable.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 097c59e1-8bbe-4846-a79e-3e661e6645c4
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/FvHcc7z
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-05-15 10:27:35 +00:00
parent e0a4652947
commit f16f4763df
5 changed files with 126 additions and 10 deletions
+26 -2
View File
@@ -22,11 +22,30 @@ MOCKUP_PORT=8081
# In compose this is set automatically; required when running natively. # In compose this is set automatically; required when running natively.
PORT=8080 PORT=8080
# Public base URL the SPA + API are reachable at. Used to build absolute # Public base URL the SPA + API are reachable at. Used to build absolute
# upload URLs for the local-FS storage driver and to validate CORS origins. # upload URLs for the local-FS storage driver and to derive whether the
# session cookie should be marked `secure`. Match the URL operators
# actually open in the browser (e.g. https://tx.example.com).
PUBLIC_BASE_URL=http://localhost:3000 PUBLIC_BASE_URL=http://localhost:3000
# Comma-separated list of origins allowed by the API server's CORS layer. # Comma-separated list of origins allowed by the API server's CORS layer.
# Must include PUBLIC_BASE_URL and any additional reverse-proxy domains. # MUST include every URL the SPA can be reached at — the public domain,
# any reverse-proxy hostnames, the LAN IP for tablets/phones, and any
# tunneled URL (Tailscale `*.ts.net`, ngrok, Cloudflare Tunnel, etc).
# Examples:
# ALLOWED_ORIGINS=http://localhost:3000
# ALLOWED_ORIGINS=https://tx.example.com,https://it-demo.tail70b2bc.ts.net
# ALLOWED_ORIGINS=http://localhost:3000,http://192.168.1.42:3000
ALLOWED_ORIGINS=http://localhost:3000 ALLOWED_ORIGINS=http://localhost:3000
# Set to `true` when Tx OS sits behind a TLS-terminating proxy that
# does NOT forward `X-Forwarded-Proto: https` (notably `tailscale serve`,
# the ngrok free tier, and some Cloudflare Tunnel setups). Without this,
# the session cookie is silently dropped and login bounces back to the
# login screen even on a successful POST /auth/login. Leave unset for
# direct localhost access or when your proxy forwards the header.
# SECURITY: only enable when the API is not reachable directly over plain
# HTTP from outside — i.e. only your TLS edge proxy can hit it. Otherwise
# anyone on the same network can submit plain-HTTP requests that the app
# trusts as HTTPS.
TRUST_PROXY_HTTPS=false
# pino log level: trace | debug | info | warn | error | fatal # pino log level: trace | debug | info | warn | error | fatal
LOG_LEVEL=info LOG_LEVEL=info
# 32+ random bytes. Generate with `openssl rand -hex 32`. REQUIRED. # 32+ random bytes. Generate with `openssl rand -hex 32`. REQUIRED.
@@ -125,6 +144,11 @@ LOCAL_STORAGE_SIGNING_SECRET=
# the first-run wizard at /setup. # the first-run wizard at /setup.
SEED_ADMIN_PASSWORD= SEED_ADMIN_PASSWORD=
SEED_USER_PASSWORD= SEED_USER_PASSWORD=
# Set to `true` to populate the Executive Meetings module with a full
# day of realistic-looking demo meetings on first boot (useful for
# screenshots, sales demos, and UI walkthroughs). Leave unset on real
# self-hosted installs — the module should start empty.
SEED_DEMO_MEETINGS=false
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Email (optional) # Email (optional)
+40 -1
View File
@@ -116,6 +116,43 @@ Front the SPA (port `WEB_PORT`, default `3000`) with Caddy / Nginx / Traefik
for TLS and HTTP/2; bind the API port (`API_PORT`, default `8080`) to for TLS and HTTP/2; bind the API port (`API_PORT`, default `8080`) to
`127.0.0.1` in production so the edge proxy is the sole external entry. `127.0.0.1` in production so the edge proxy is the sole external entry.
### Reverse proxy / Tailscale / external URL
If you front Tx OS with a TLS-terminating proxy that you don't control
(Tailscale `serve`, ngrok, Cloudflare Tunnel, …), two things matter:
1. **Add the public URL to `ALLOWED_ORIGINS` in `.env`.** If you skip
this, every authenticated request fails CORS and login appears to
succeed but the next request 500s.
```bash
ALLOWED_ORIGINS=http://localhost:3000,https://it-demo.tail70b2bc.ts.net
PUBLIC_BASE_URL=https://it-demo.tail70b2bc.ts.net
```
2. **Set `TRUST_PROXY_HTTPS=true` in `.env`.** Tunnels like Tailscale
serve forward HTTPS traffic to the upstream as plain HTTP without
sending an `X-Forwarded-Proto: https` header. Without this flag,
`req.secure` is false, the session cookie is silently dropped, and
the user is bounced back to the login screen on every refresh.
> **Security note**: only enable `TRUST_PROXY_HTTPS=true` when the
> API container/port is **not** reachable directly over plain HTTP
> from outside (i.e. only the TLS edge proxy can hit it). The
> default Docker compose only exposes the SPA port (`3000`) to the
> host and keeps the API on the internal `tx-net` network, which
> satisfies this requirement. If you change the compose file to
> expose the API port publicly, do not enable this flag.
After editing `.env`, restart the stack:
```bash
docker compose up -d
```
You should be able to log in via the public HTTPS URL with no other
changes.
### Default seeded accounts ### Default seeded accounts
| Username | Password | Role | | Username | Password | Role |
@@ -171,7 +208,9 @@ complete list with comments. Highlights:
| --------------------------------- | ---------------------------------------------------------------------- | | --------------------------------- | ---------------------------------------------------------------------- |
| `DATABASE_URL` | Postgres connection string. **Required.** | | `DATABASE_URL` | Postgres connection string. **Required.** |
| `SESSION_SECRET` | HMAC key for session cookies + local-driver upload tokens. **Required in production.** | | `SESSION_SECRET` | HMAC key for session cookies + local-driver upload tokens. **Required in production.** |
| `ALLOWED_ORIGINS` | Comma-separated CORS allow-list. Defaults to `*` (dev only). | | `ALLOWED_ORIGINS` | Comma-separated CORS allow-list. MUST list every URL the SPA is reached at (LAN IP, Tailscale name, custom domain, ...). |
| `TRUST_PROXY_HTTPS` | `true` when fronted by a TLS-terminating proxy that doesn't forward `X-Forwarded-Proto` (Tailscale serve, ngrok free, some Cloudflare Tunnels). Required for the session cookie to persist on those setups. |
| `SEED_DEMO_MEETINGS` | `true` to populate Executive Meetings with a day of demo data on first boot. Default off so real installs start empty. |
| `STORAGE_DRIVER` | `s3` or `local`. Defaults to `s3` if `S3_ENDPOINT` set, else `local`. | | `STORAGE_DRIVER` | `s3` or `local`. Defaults to `s3` if `S3_ENDPOINT` set, else `local`. |
| `S3_ENDPOINT` etc. | MinIO / S3 connection. Required when `STORAGE_DRIVER=s3`. | | `S3_ENDPOINT` etc. | MinIO / S3 connection. Required when `STORAGE_DRIVER=s3`. |
| `PRIVATE_OBJECT_DIR` | Path inside the bucket for private uploads, e.g. `/tx-private/private`.| | `PRIVATE_OBJECT_DIR` | Path inside the bucket for private uploads, e.g. `/tx-private/private`.|
+39 -1
View File
@@ -13,6 +13,25 @@ const app: Express = express();
app.set("trust proxy", 1); app.set("trust proxy", 1);
// Self-hosted deployments are commonly fronted by a TLS-terminating
// proxy that does NOT forward `X-Forwarded-Proto: https` to the
// upstream (e.g. `tailscale serve`, ngrok free tier, some Cloudflare
// Tunnel configs). Without that header, `req.secure` stays false and
// the session cookie (when marked `secure: true`) is silently dropped
// — login appears to succeed but the very next request is unauth'd.
//
// Setting `TRUST_PROXY_HTTPS=true` tells us to treat every incoming
// request as if it arrived over HTTPS. Only enable this when the
// edge proxy you control actually terminates TLS in front of Tx OS.
const trustProxyHttps =
(process.env.TRUST_PROXY_HTTPS ?? "").toLowerCase() === "true";
if (trustProxyHttps) {
app.use((req, _res, next) => {
req.headers["x-forwarded-proto"] = "https";
next();
});
}
app.use( app.use(
pinoHttp({ pinoHttp({
logger, logger,
@@ -55,11 +74,30 @@ app.use(
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// The session cookie should only be marked `secure` when the user is
// actually reaching us over HTTPS. We derive that from PUBLIC_BASE_URL
// instead of NODE_ENV so a self-hosted production install that's only
// reachable over plain HTTP (e.g. internal LAN, no TLS yet) still
// works without operator surgery.
const publicBaseUrl = process.env.PUBLIC_BASE_URL ?? "";
const sessionCookieSecure =
publicBaseUrl.startsWith("https://") || trustProxyHttps;
export const sessionMiddleware = session({ export const sessionMiddleware = session({
store: new PgSession({ store: new PgSession({
pool, pool,
tableName: "user_sessions", tableName: "user_sessions",
// Auto-create the session table on first boot. Drizzle's migrations
// intentionally exclude `user_sessions` (see lib/db/drizzle.config.ts)
// because the schema is owned by connect-pg-simple, not by us. Without
// this flag, the very first login on a fresh install 500s.
createTableIfMissing: true,
}), }),
// Honour `X-Forwarded-Proto` from upstream proxies when deciding
// whether to set the secure cookie. Combined with `app.set("trust
// proxy", 1)` above, this makes the cookie work end-to-end behind
// Caddy / nginx / Cloudflare / Tailscale.
proxy: true,
secret: process.env.SESSION_SECRET ?? (() => { secret: process.env.SESSION_SECRET ?? (() => {
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
throw new Error("SESSION_SECRET must be set in production"); throw new Error("SESSION_SECRET must be set in production");
@@ -69,7 +107,7 @@ export const sessionMiddleware = session({
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: process.env.NODE_ENV === "production", secure: sessionCookieSecure,
httpOnly: true, httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000, maxAge: 7 * 24 * 60 * 60 * 1000,
sameSite: "lax", sameSite: "lax",
+2
View File
@@ -32,6 +32,7 @@ services:
# the first-run Setup Wizard (/api/setup/complete). # the first-run Setup Wizard (/api/setup/complete).
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-} SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-}
SEED_DEMO_MEETINGS: ${SEED_DEMO_MEETINGS:-false}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}}
user: root user: root
entrypoint: ["/usr/bin/tini", "--"] entrypoint: ["/usr/bin/tini", "--"]
@@ -62,6 +63,7 @@ services:
DEFAULT_OBJECT_STORAGE_BUCKET_ID: ${DEFAULT_OBJECT_STORAGE_BUCKET_ID:-tx-local} DEFAULT_OBJECT_STORAGE_BUCKET_ID: ${DEFAULT_OBJECT_STORAGE_BUCKET_ID:-tx-local}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${APP_PORT:-3000}} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${APP_PORT:-3000}}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}}
TRUST_PROXY_HTTPS: ${TRUST_PROXY_HTTPS:-false}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
HTTPS_MODE: ${HTTPS_MODE:-local} HTTPS_MODE: ${HTTPS_MODE:-local}
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-} LOCAL_DOMAIN: ${LOCAL_DOMAIN:-}
+19 -6
View File
@@ -646,13 +646,26 @@ async function main() {
} }
} }
// Executive meetings: sample data for today (idempotent per-date) // Executive meetings: sample data for today (idempotent per-date).
//
// Opt-in only — a vanilla self-hosted install should start with an
// empty Executive Meetings module. Set `SEED_DEMO_MEETINGS=true` in
// your environment when bootstrapping a demo / staging deploy that
// benefits from realistic-looking sample data.
const seedDemoMeetings =
(process.env.SEED_DEMO_MEETINGS ?? "").toLowerCase() === "true";
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const existingForToday = await db const existingForToday = seedDemoMeetings
.select({ id: executiveMeetingsTable.id }) ? await db
.from(executiveMeetingsTable) .select({ id: executiveMeetingsTable.id })
.where(eq(executiveMeetingsTable.meetingDate, today)); .from(executiveMeetingsTable)
if (existingForToday.length === 0) { .where(eq(executiveMeetingsTable.meetingDate, today))
: [];
if (!seedDemoMeetings) {
console.log(
"Executive Meetings demo data skipped (set SEED_DEMO_MEETINGS=true to enable)",
);
} else if (existingForToday.length === 0) {
{ {
const sampleMeetings: Array<{ const sampleMeetings: Array<{
meeting: { meeting: {