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:
+26
-2
@@ -22,11 +22,30 @@ MOCKUP_PORT=8081
|
||||
# In compose this is set automatically; required when running natively.
|
||||
PORT=8080
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
LOG_LEVEL=info
|
||||
# 32+ random bytes. Generate with `openssl rand -hex 32`. REQUIRED.
|
||||
@@ -125,6 +144,11 @@ LOCAL_STORAGE_SIGNING_SECRET=
|
||||
# the first-run wizard at /setup.
|
||||
SEED_ADMIN_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)
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
|
||||
| Username | Password | Role |
|
||||
@@ -171,7 +208,9 @@ complete list with comments. Highlights:
|
||||
| --------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | Postgres connection string. **Required.** |
|
||||
| `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`. |
|
||||
| `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`.|
|
||||
|
||||
@@ -13,6 +13,25 @@ const app: Express = express();
|
||||
|
||||
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(
|
||||
pinoHttp({
|
||||
logger,
|
||||
@@ -55,11 +74,30 @@ app.use(
|
||||
app.use(express.json());
|
||||
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({
|
||||
store: new PgSession({
|
||||
pool,
|
||||
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 ?? (() => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("SESSION_SECRET must be set in production");
|
||||
@@ -69,7 +107,7 @@ export const sessionMiddleware = session({
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: sessionCookieSecure,
|
||||
httpOnly: true,
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
sameSite: "lax",
|
||||
|
||||
@@ -32,6 +32,7 @@ services:
|
||||
# the first-run Setup Wizard (/api/setup/complete).
|
||||
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_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}}
|
||||
user: root
|
||||
entrypoint: ["/usr/bin/tini", "--"]
|
||||
@@ -62,6 +63,7 @@ services:
|
||||
DEFAULT_OBJECT_STORAGE_BUCKET_ID: ${DEFAULT_OBJECT_STORAGE_BUCKET_ID:-tx-local}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-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}
|
||||
HTTPS_MODE: ${HTTPS_MODE:-local}
|
||||
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-}
|
||||
|
||||
+19
-6
@@ -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 existingForToday = await db
|
||||
.select({ id: executiveMeetingsTable.id })
|
||||
.from(executiveMeetingsTable)
|
||||
.where(eq(executiveMeetingsTable.meetingDate, today));
|
||||
if (existingForToday.length === 0) {
|
||||
const existingForToday = seedDemoMeetings
|
||||
? await db
|
||||
.select({ id: executiveMeetingsTable.id })
|
||||
.from(executiveMeetingsTable)
|
||||
.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<{
|
||||
meeting: {
|
||||
|
||||
Reference in New Issue
Block a user