fail closed when PUBLIC_BUILD_ENV is unset
Origin validation, every rate limiter and the password-strength check each treated an unset PUBLIC_BUILD_ENV as "relaxed", so a deployment that forgot the variable silently ran with CORS reflecting any origin, no brute-force limit on the sole account, and no password rules. setup.sh writes it, but .env.example never mentioned it. The three now share IS_DEV_BUILD, which is true only when PUBLIC_BUILD_ENV is explicitly "dev" or "development". Anything else, including unset, is hardened. Documented in .env.example. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
85596da086
commit
8163f04420
@@ -3,6 +3,12 @@ JWT_SECRET="<generate with: openssl rand -base64 32>"
|
|||||||
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
||||||
MAIL_TRANSPORT="smtp://localhost:1025"
|
MAIL_TRANSPORT="smtp://localhost:1025"
|
||||||
PUBLIC_URL=http://localhost:9000
|
PUBLIC_URL=http://localhost:9000
|
||||||
|
|
||||||
|
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
||||||
|
# "dev" or "development". Leave it unset or set it to "production" for a real deployment; only set
|
||||||
|
# it to "dev" on a local machine you trust, since that disables all three.
|
||||||
|
PUBLIC_BUILD_ENV=production
|
||||||
|
|
||||||
DATA_PATH=/path/to/data
|
DATA_PATH=/path/to/data
|
||||||
OFFICER_ITEMS_DIR=/path/to/officer-items
|
OFFICER_ITEMS_DIR=/path/to/officer-items
|
||||||
HOME_DIR=/home/user
|
HOME_DIR=/home/user
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { MiddlewareHandler } from 'hono';
|
import type { MiddlewareHandler } from 'hono';
|
||||||
import * as errors from '../custom-errors';
|
import * as errors from '../custom-errors';
|
||||||
|
import { IS_DEV_BUILD } from '../build-env';
|
||||||
|
|
||||||
const { PUBLIC_BUILD_ENV, PUBLIC_URL, EXPO_PUBLIC_CLIENT_ORIGIN } = process.env;
|
const { PUBLIC_URL, EXPO_PUBLIC_CLIENT_ORIGIN } = process.env;
|
||||||
|
|
||||||
// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
|
// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
|
||||||
// not a hardcoded domain.
|
// not a hardcoded domain.
|
||||||
@@ -13,9 +14,7 @@ const PUBLIC_ORIGIN = (() => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const WEB_ORIGINS: Record<string, string[]> = {
|
const WEB_ORIGINS: string[] = PUBLIC_ORIGIN ? [PUBLIC_ORIGIN] : [];
|
||||||
production: PUBLIC_ORIGIN ? [PUBLIC_ORIGIN] : [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const CHROME_EXTENSIONS: string[] = [
|
const CHROME_EXTENSIONS: string[] = [
|
||||||
// 'chrome-extension://<id>'
|
// 'chrome-extension://<id>'
|
||||||
@@ -27,9 +26,7 @@ const APP_ORIGINS: string[] = [
|
|||||||
].filter((o): o is string => Boolean(o));
|
].filter((o): o is string => Boolean(o));
|
||||||
|
|
||||||
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
|
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
|
||||||
if (!PUBLIC_BUILD_ENV || PUBLIC_BUILD_ENV === 'dev' || PUBLIC_BUILD_ENV === 'development') {
|
if (IS_DEV_BUILD) return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (origin) {
|
if (origin) {
|
||||||
if (origin.startsWith('chrome-extension://')) {
|
if (origin.startsWith('chrome-extension://')) {
|
||||||
@@ -40,13 +37,11 @@ export function isOriginAllowed(origin: string | undefined, host?: string): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowed = WEB_ORIGINS[PUBLIC_BUILD_ENV];
|
return WEB_ORIGINS.includes(origin);
|
||||||
return !!allowed?.includes(origin);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (host) {
|
if (host) {
|
||||||
const allowed = WEB_ORIGINS[PUBLIC_BUILD_ENV];
|
return WEB_ORIGINS.some((o) => o.endsWith(host));
|
||||||
return !!allowed?.some((o) => o.endsWith(host));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Context, MiddlewareHandler } from 'hono';
|
import type { Context, MiddlewareHandler } from 'hono';
|
||||||
import * as errors from '../custom-errors';
|
import * as errors from '../custom-errors';
|
||||||
|
import { IS_DEV_BUILD } from '../build-env';
|
||||||
|
|
||||||
type RateLimitEntry = {
|
type RateLimitEntry = {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -24,9 +25,6 @@ function cleanupStore(store: Map<string, RateLimitEntry>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { PUBLIC_BUILD_ENV } = process.env;
|
|
||||||
const isProduction = PUBLIC_BUILD_ENV === 'production' || PUBLIC_BUILD_ENV === 'staging';
|
|
||||||
|
|
||||||
export function rateLimiter(options: RateLimiterOptions): MiddlewareHandler {
|
export function rateLimiter(options: RateLimiterOptions): MiddlewareHandler {
|
||||||
const {
|
const {
|
||||||
windowMs,
|
windowMs,
|
||||||
@@ -43,7 +41,7 @@ export function rateLimiter(options: RateLimiterOptions): MiddlewareHandler {
|
|||||||
setInterval(() => cleanupStore(store), 60_000);
|
setInterval(() => cleanupStore(store), 60_000);
|
||||||
|
|
||||||
return async (ctx, next) => {
|
return async (ctx, next) => {
|
||||||
if (!isProduction) return next();
|
if (IS_DEV_BUILD) return next();
|
||||||
|
|
||||||
const key = keyGenerator(ctx);
|
const key = keyGenerator(ctx);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import argon2 from 'argon2';
|
|||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { validatePassword } from './validate-password';
|
import { validatePassword } from './validate-password';
|
||||||
|
import { IS_DEV_BUILD } from '@@/build-env';
|
||||||
const { PUBLIC_BUILD_ENV } = process.env;
|
|
||||||
const isProduction = PUBLIC_BUILD_ENV === 'production' || PUBLIC_BUILD_ENV === 'staging';
|
|
||||||
|
|
||||||
export const changePasswordHandler: Handler = async function (ctx) {
|
export const changePasswordHandler: Handler = async function (ctx) {
|
||||||
const { password, newPassword } = ctx.get('body');
|
const { password, newPassword } = ctx.get('body');
|
||||||
if (isProduction) validatePassword(newPassword);
|
if (!IS_DEV_BUILD) validatePassword(newPassword);
|
||||||
const reqUser = ctx.get('user');
|
const reqUser = ctx.get('user');
|
||||||
|
|
||||||
const dbUser = await getUserById(reqUser.id);
|
const dbUser = await getUserById(reqUser.id);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Officer relaxes its guards (origin checks, rate limits, password rules) only when PUBLIC_BUILD_ENV
|
||||||
|
// explicitly asks for it. Anything else — including an unset variable — gets the hardened path, so a
|
||||||
|
// deployment that forgets to set it fails closed rather than silently opening up.
|
||||||
|
const { PUBLIC_BUILD_ENV } = process.env;
|
||||||
|
|
||||||
|
export const IS_DEV_BUILD = PUBLIC_BUILD_ENV === 'dev' || PUBLIC_BUILD_ENV === 'development';
|
||||||
Reference in New Issue
Block a user