Wave 2 of the remediation plan. Eight findings from the feature test run were the same defect twice: a write that reports success while storing something other than what was sent. The first half is a date parser that swallowed its own error. Two plugins carried an identical private _parse_date returning None on ValueError, so an unparseable date and a deliberately cleared one produced the same write. PUT /api/warranty/<id> with enddate '30-06-2027' answered 200 "Warranty updated" and stored NULL, dropping the record out of every expiry surface on the strength of a typo; the measuring-tool calibration date did the same and flipped the tool to 'unknown', out of the calibration report and the map overlay. shopdb/utils/datevalues.py now holds one strict parser on the contract surface: an explicitly empty value clears the field, anything that is not a date is refused with a 400 naming the field, and those are different intents with different answers. The vendor-lookup paths keep a lenient wrapper on purpose - a provider changing its date shape must not fail the refresh of an otherwise good record, and no person typed that value. The second half is why the truncation findings were possible at all. MySQL was running non-strict: sql_mode on the 5.6 server is NO_ENGINE_SUBSTITUTION with no STRICT_TRANS_TABLES, so it accepted writes it could not store and adjusted them. A 257-character username became its first 100 characters (and the next distinct one then collided with it); an over-long asset number was cut at 50; a malformed releasedate became '0000-00-00' and permanently broke every read of that application. All with a 201. The mode is now set on the CONNECTION rather than left to the server, so a site whose my.cnf disagrees still gets it. That trades silent corruption for a DataError, which uncaught is a 500 - and a 500 says the server is broken when the truth is that the value does not fit. An error handler maps it to a 400 naming the column where MySQL names one. Verified by hand against MySQL 5.6: POST /api/users with a 257-character username now answers 400 "That value is too long for username." and stores nothing, where it previously stored a truncated row and answered 201. Twelve tests. The date guard is verified by planting: restore the swallowing parser and four of them fail. The DataError tests raise the error directly because the suite runs on SQLite, which does not enforce VARCHAR length at all - there is no value that would trigger it there, which is precisely why this went unnoticed. Scope: product
260 lines
11 KiB
Python
260 lines
11 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.
|
|
#
|
|
# STRICT_TRANS_TABLES is the other half, and it is a correctness
|
|
# setting rather than a preference. Without it MySQL accepts a write it
|
|
# cannot store and adjusts it: a 257-character username becomes the
|
|
# first 100 characters, a malformed date becomes '0000-00-00', an
|
|
# over-long asset number is cut at 50 and then collides with a
|
|
# different one. Every one of those was found in the product answering
|
|
# 200 or 201, because nothing in the stack knew the value had changed.
|
|
#
|
|
# Set on the CONNECTION rather than left to the server, so a site whose
|
|
# my.cnf says otherwise still gets it - the sql_mode seen on the 5.6
|
|
# dev server was NO_ENGINE_SUBSTITUTION alone. NO_ENGINE_SUBSTITUTION is
|
|
# kept alongside it, since dropping it would change how a CREATE TABLE
|
|
# naming an unavailable engine behaves.
|
|
'connect_args': {
|
|
'charset': 'utf8mb4',
|
|
'init_command':
|
|
"SET SESSION sql_mode='STRICT_TRANS_TABLES,"
|
|
"NO_ENGINE_SUBSTITUTION'",
|
|
},
|
|
}
|
|
|
|
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,
|
|
}
|