Six fixes that were ours regardless of what the sister site does. Four came out of our own reviews; two you reported from the settings pages. MAX_CONTENT_LENGTH was set nowhere. Every upload route caps its own payload, but each of those checks runs after the body has arrived: the first touch of request.form or request.files makes Werkzeug parse the whole multipart and spool the file to a temp file, so a route can reject the content and still have paid for the transfer and the disk. Three separate reviews found that shape in three different routes, which makes it a property of the app rather than of any handler. 128 MB, clearing the 64 MB agent binary with room for the envelope. The naming gate excluded devnotes with --exclude-dir, which matches a directory of that NAME at any depth - so a force-added plugins/<plugin>/devnotes/ was invisible to every check in the file, ADR-015 included. Replaced with a path anchored post-filter. Verified by planting the same site literal in root devnotes/ AND in plugins/endpoints/devnotes/: the gate now reports exactly one, the nested one. Before, it reported none. BaseModel.to_dict walked __table__.columns and published every one, so a model without an override published whatever it carried - including columns added later by someone not thinking about this method. User has no override, so User.to_dict() returned its bcrypt hash. Nothing calls it in a response path today, which is luck rather than design. A blanket allowlist would empty the output of all 34 models relying on the default, so this pins a floor instead: named secret-bearing columns are never emitted, and a model may extend the set. Eight tests, including a sweep over every mapped model. ActionPicker's Esc handlers had .prevent but not .stop, so the event bubbled to the wrapper and closed the whole picker instead of returning to the monitor list. The branding page reported "error loading image" on the site and badge logos. Two bugs stacked. The load failure was real: the preview used the stored value as a raw src, and that value is root-relative, so under /shopdb or /ops it resolved against the SERVER root and 404d - siteSettings.js wraps every logo in withBase() and this page was the one place that did not. With that fixed two of three were still invisible, because the shipped GE mark is fill="white" for the dark sidebar and was rendering white-on-white. The preview tile now sits on a mid-tone checker so artwork of any colour reads against it. The floor-map levels table ran off the right with its cells crushed - nine columns of inputs, no scroll wrapper, and .data-table is styled nowhere. My first attempt reached for the global .table-container and made it WORSE: that class also sets white-space: nowrap, and a full sentence of hint text lives in one of the cells, so nowrap forced the table wider still. It now scrolls in a local wrapper, the hint wraps, and the name input has a width instead of collapsing to one character. Suite 2377, frontend 324, gate green. Scope: product
241 lines
9.5 KiB
Python
241 lines
9.5 KiB
Python
"""Flask application configuration."""
|
|
|
|
import os
|
|
from datetime import timedelta
|
|
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Raised when required configuration is missing or unsafe."""
|
|
|
|
|
|
def _required_env(varname):
|
|
"""Read an env var; raise ConfigError if missing or empty."""
|
|
value = os.environ.get(varname)
|
|
if not value:
|
|
raise ConfigError(
|
|
f'{varname} is required in production. Set it in the environment '
|
|
f'before starting the app. Insecure defaults are not permitted in '
|
|
f'ProductionConfig.'
|
|
)
|
|
return value
|
|
|
|
|
|
class Config:
|
|
"""Base configuration."""
|
|
|
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
|
|
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
|
'DATABASE_URL',
|
|
'mysql+pymysql://root:password@localhost:3306/shopdb_flask',
|
|
)
|
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
|
'pool_pre_ping': True,
|
|
'pool_recycle': 300,
|
|
# Pin the client connection to utf8mb4 so reads/writes match the
|
|
# utf8mb4 schema regardless of the server default charset. TestingConfig
|
|
# (SQLite) replaces this whole dict, so the charset arg never reaches it.
|
|
'connect_args': {'charset': 'utf8mb4'},
|
|
}
|
|
|
|
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')
|
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(
|
|
seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
|
)
|
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(
|
|
seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000))
|
|
)
|
|
|
|
# Number of reverse proxies in front of this instance. 0 (the default)
|
|
# means none, and the app reads the connection it actually receives.
|
|
# Set to 1 when IIS or nginx fronts it, so X-Forwarded-Proto/Host/Prefix are
|
|
# honoured and a generated absolute URL matches what the browser asked for.
|
|
# Do NOT set it without a proxy: it makes the app believe forwarded headers
|
|
# a client can send itself, and the login rate limit and account lockout are
|
|
# keyed on the client address.
|
|
TRUSTED_PROXY_COUNT = int(os.environ.get('TRUSTED_PROXY_COUNT', 0))
|
|
|
|
CORS_ORIGINS = [
|
|
origin.strip()
|
|
for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',')
|
|
if origin.strip()
|
|
]
|
|
|
|
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
|
|
|
|
# API key for the unattended PowerShell collector scripts
|
|
COLLECTOR_API_KEY = os.environ.get('COLLECTOR_API_KEY', '')
|
|
|
|
# Trusted plugin publisher public keys (ADR-013). os.pathsep-separated PEM
|
|
# file paths, delivered out-of-band with the deployed config - NEVER read
|
|
# from the plugin shelf. Used to verify signed plugin artifacts. Empty on a
|
|
# site that does not adopt marketplace plugins.
|
|
PLUGIN_TRUSTED_KEYS = [
|
|
path.strip()
|
|
for path in os.environ.get('PLUGIN_TRUSTED_KEYS', '').split(os.pathsep)
|
|
if path.strip()
|
|
]
|
|
|
|
# When true, a plugin only loads/migrates if its tree matches a trusted
|
|
# signature (verify-at-load, verify-at-migrate). Default false = existing
|
|
# behavior. Turn on only after stamping plugins + pinning keys.
|
|
PLUGIN_REQUIRE_SIGNED = os.environ.get(
|
|
'PLUGIN_REQUIRE_SIGNED', 'false').lower() == 'true'
|
|
|
|
# Directories whose unsigned plugins are trusted - honored ONLY under
|
|
# DEBUG/TESTING (the external-repo/symlink dev workflow). Production ignores.
|
|
PLUGIN_DEV_TRUST_DIRS = [
|
|
path.strip()
|
|
for path in os.environ.get('PLUGIN_DEV_TRUST_DIRS', '').split(os.pathsep)
|
|
if path.strip()
|
|
]
|
|
|
|
# Read-only folder the app pulls plugin artifacts from (a SharePoint-synced
|
|
# or copied shelf). Empty = no shelf configured. The app never speaks any
|
|
# network protocol; it reads this folder.
|
|
PLUGIN_SHELF_DIR = os.environ.get('PLUGIN_SHELF_DIR', '')
|
|
|
|
ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true'
|
|
ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
|
|
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')
|
|
|
|
# Read-only HR/employee directory database (separate from the app DB).
|
|
# Credentials come from the environment; never hardcode them in source.
|
|
# No safe default for the password: unset means empty, the connection
|
|
# fails loud rather than silently trying a guessed credential.
|
|
EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost')
|
|
EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', '')
|
|
EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', '')
|
|
EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees')
|
|
|
|
# Read-write CMMC USB check-in/out database (separate from the app DB).
|
|
# Credentials come from the environment; never hardcode them in source.
|
|
# No safe default for the password: unset means empty, the connection
|
|
# fails loud rather than silently trying a guessed credential.
|
|
CMMC_USB_DB_HOST = os.environ.get('CMMC_USB_DB_HOST', 'localhost')
|
|
CMMC_USB_DB_USER = os.environ.get('CMMC_USB_DB_USER', '')
|
|
CMMC_USB_DB_PASSWORD = os.environ.get('CMMC_USB_DB_PASSWORD', '')
|
|
CMMC_USB_DB_NAME = os.environ.get('CMMC_USB_DB_NAME', 'cmmc_usb')
|
|
|
|
CACHE_TYPE = 'SimpleCache'
|
|
CACHE_DEFAULT_TIMEOUT = 600
|
|
|
|
# IP-based login rate limit (fixed window). Defense in depth atop the
|
|
# per-account lockout. Backed by the existing cache extension.
|
|
AUTH_RATELIMIT_ENABLED = os.environ.get(
|
|
'AUTH_RATELIMIT_ENABLED', 'true').lower() == 'true'
|
|
AUTH_RATELIMIT_MAX = int(os.environ.get('AUTH_RATELIMIT_MAX', 30))
|
|
AUTH_RATELIMIT_WINDOW_SECONDS = int(
|
|
os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300))
|
|
|
|
DEFAULT_PAGE_SIZE = 20
|
|
MAX_PAGE_SIZE = 100
|
|
|
|
# Hard ceiling on a request body, enforced by Werkzeug before a view runs.
|
|
#
|
|
# Upload routes cap their own payloads, but every one of those checks runs
|
|
# AFTER the body has arrived: the first touch of request.form or
|
|
# request.files makes Werkzeug parse the whole multipart and spool the file
|
|
# part to a temp file, so a route can reject the content and still have paid
|
|
# for the transfer and the disk. Three separate reviews found that shape in
|
|
# three different routes, which makes it a property of the app rather than
|
|
# of any handler.
|
|
#
|
|
# 128 MB clears the largest thing anyone legitimately sends - the agent
|
|
# binary upload caps itself at 64 MB - with room for the multipart envelope.
|
|
# Over this, Werkzeug answers 413 without reading the body.
|
|
MAX_CONTENT_LENGTH = int(
|
|
os.environ.get('MAX_CONTENT_LENGTH', 128 * 1024 * 1024))
|
|
|
|
|
|
class DevelopmentConfig(Config):
|
|
"""Development configuration."""
|
|
|
|
DEBUG = True
|
|
SQLALCHEMY_ECHO = True
|
|
|
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
|
'DATABASE_URL',
|
|
'mysql+pymysql://root:changeme@127.0.0.1:3306/shopdb_flask',
|
|
)
|
|
|
|
|
|
class TestingConfig(Config):
|
|
"""Testing configuration."""
|
|
|
|
TESTING = True
|
|
# Off by default so login-heavy fixtures do not trip the limiter; tests
|
|
# that exercise rate limiting flip it on via app.config override.
|
|
AUTH_RATELIMIT_ENABLED = False
|
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
|
'connect_args': {'check_same_thread': False},
|
|
'poolclass': StaticPool,
|
|
}
|
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5)
|
|
|
|
|
|
class ProductionConfig(Config):
|
|
"""Production configuration.
|
|
|
|
Validation is deferred to validate() so that importing this class in a
|
|
non-production environment (tests, dev, tooling) does not raise.
|
|
create_app() invokes validate() when config_name == 'production' so
|
|
a misconfigured production deploy still fails loud at boot.
|
|
"""
|
|
|
|
DEBUG = False
|
|
SQLALCHEMY_ECHO = False
|
|
|
|
JWT_COOKIE_SECURE = True
|
|
JWT_COOKIE_CSRF_PROTECT = True
|
|
|
|
@classmethod
|
|
def validate(cls):
|
|
"""Verify production config is safe. Called from create_app."""
|
|
secret_key = os.environ.get('SECRET_KEY', '')
|
|
jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
|
|
database_url = os.environ.get('DATABASE_URL', '')
|
|
cors_raw = os.environ.get('CORS_ORIGINS', '').strip()
|
|
|
|
insecure_defaults = {
|
|
'dev-secret-key-change-in-production',
|
|
'jwt-secret-key-change-in-production',
|
|
}
|
|
|
|
if not secret_key or secret_key in insecure_defaults:
|
|
raise ConfigError(
|
|
'SECRET_KEY is required in production and must not be the '
|
|
'development default. Set a strong random value in the '
|
|
'environment before starting the app.'
|
|
)
|
|
if not jwt_secret or jwt_secret in insecure_defaults:
|
|
raise ConfigError(
|
|
'JWT_SECRET_KEY is required in production and must not be '
|
|
'the development default. Set a strong random value in the '
|
|
'environment before starting the app.'
|
|
)
|
|
if not database_url:
|
|
raise ConfigError(
|
|
'DATABASE_URL is required in production. No fallback to a '
|
|
'development localhost URL is permitted.'
|
|
)
|
|
if not cors_raw or cors_raw == '*':
|
|
raise ConfigError(
|
|
'CORS_ORIGINS must be a comma-separated allowlist of '
|
|
'explicit origins in production. Wildcard "*" is not '
|
|
'permitted. Example: '
|
|
'CORS_ORIGINS=https://shopdb.example.com,https://shopdb-mirror.example.com'
|
|
)
|
|
|
|
|
|
config = {
|
|
'development': DevelopmentConfig,
|
|
'testing': TestingConfig,
|
|
'production': ProductionConfig,
|
|
'default': DevelopmentConfig,
|
|
}
|