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.
|
# 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)
|
||||||
|
|||||||
@@ -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`.|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:-}
|
||||||
|
|||||||
+17
-4
@@ -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
|
||||||
|
? await db
|
||||||
.select({ id: executiveMeetingsTable.id })
|
.select({ id: executiveMeetingsTable.id })
|
||||||
.from(executiveMeetingsTable)
|
.from(executiveMeetingsTable)
|
||||||
.where(eq(executiveMeetingsTable.meetingDate, today));
|
.where(eq(executiveMeetingsTable.meetingDate, today))
|
||||||
if (existingForToday.length === 0) {
|
: [];
|
||||||
|
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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user