From 22d0c391d5becc848db2558921fda7fe72e0124f Mon Sep 17 00:00:00 2001 From: grasfer Date: Fri, 17 Jul 2026 12:12:17 +0200 Subject: [PATCH] Auto-pick SSL cert from typed domains; secure defaults behind NPM_SECURE_DEFAULTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2. When adding a proxy host, typing a domain auto-selects a certificate covering ALL entered domains (exact > one-label wildcard), tracked as auto-picked so it re-matches or clears as domains change and never overrides a manual choice (including an explicit "None"). New env var NPM_SECURE_DEFAULTS (default off = stock behavior), exposed via the health endpoint: new proxy hosts default Block Common Exploits and Websockets Support on, and Force SSL + HTTP/2 flip on when a cert is first selected. Applies to new hosts only — editing existing hosts never changes stored values. SSLCertificateField's select is now controlled so programmatic certificateId changes render correctly. Co-Authored-By: Claude Fable 5 --- backend/lib/config.js | 63 ++++++++++++++----- backend/routes/main.js | 3 +- backend/schema/components/health-object.json | 5 ++ backend/schema/paths/get.json | 1 + docker/docker-compose.dev.yml | 1 + frontend/src/api/backend/responseTypes.ts | 1 + .../components/Form/SSLCertificateField.tsx | 12 +++- frontend/src/modals/ProxyHostModal.tsx | 45 +++++++++++-- frontend/src/modules/CertificateMatch.test.ts | 54 ++++++++++++++++ frontend/src/modules/CertificateMatch.ts | 50 +++++++++++++++ 10 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 frontend/src/modules/CertificateMatch.test.ts create mode 100644 frontend/src/modules/CertificateMatch.ts diff --git a/backend/lib/config.js b/backend/lib/config.js index a491c190e8..c85c204076 100644 --- a/backend/lib/config.js +++ b/backend/lib/config.js @@ -2,16 +2,18 @@ import fs from "node:fs"; import NodeRSA from "node-rsa"; import { global as logger } from "../logger.js"; -const keysFile = '/data/keys.json'; -const mysqlEngine = 'mysql2'; -const postgresEngine = 'pg'; -const sqliteClientName = 'better-sqlite3'; +const keysFile = "/data/keys.json"; +const mysqlEngine = "mysql2"; +const postgresEngine = "pg"; +const sqliteClientName = "better-sqlite3"; // Not used for new setups anymore but may exist in legacy setups -const legacySqliteClientName = 'sqlite3'; +const legacySqliteClientName = "sqlite3"; let instance = null; +const toBool = (v) => /^(1|true|yes|on)$/i.test((v || "").trim()); + // 1. Load from config file first (not recommended anymore) // 2. Use config env variables next const configure = () => { @@ -40,14 +42,18 @@ const configure = () => { } } - const toBool = (v) => /^(1|true|yes|on)$/i.test((v || '').trim()); - - const envMysqlHost = process.env.DB_MYSQL_HOST || null; - const envMysqlUser = process.env.DB_MYSQL_USER || null; - const envMysqlName = process.env.DB_MYSQL_NAME || null; - const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL); - const envMysqlSSLRejectUnauthorized = process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined ? true : toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED); - const envMysqlSSLVerifyIdentity = process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined ? true : toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY); + const envMysqlHost = process.env.DB_MYSQL_HOST || null; + const envMysqlUser = process.env.DB_MYSQL_USER || null; + const envMysqlName = process.env.DB_MYSQL_NAME || null; + const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL); + const envMysqlSSLRejectUnauthorized = + process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined + ? true + : toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED); + const envMysqlSSLVerifyIdentity = + process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined + ? true + : toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY); if (envMysqlHost && envMysqlUser && envMysqlName) { // we have enough mysql creds to go with mysql logger.info("Using MySQL configuration"); @@ -58,8 +64,10 @@ const configure = () => { port: process.env.DB_MYSQL_PORT || 3306, user: envMysqlUser, password: process.env.DB_MYSQL_PASSWORD, - name: envMysqlName, - ssl: envMysqlSSL ? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity } : false, + name: envMysqlName, + ssl: envMysqlSSL + ? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity } + : false, }, keys: getKeys(), }; @@ -222,7 +230,7 @@ const isDebugMode = () => !!process.env.DEBUG; * * @returns {boolean} */ -const isCI = () => process.env.CI === 'true' && process.env.DEBUG === 'true'; +const isCI = () => process.env.CI === "true" && process.env.DEBUG === "true"; /** * Returns a public key @@ -249,6 +257,14 @@ const getPrivateKey = () => { */ const useLetsencryptStaging = () => !!process.env.LE_STAGING; +/** + * Whether new hosts should default to the secure options in the UI + * (block exploits, websockets, force ssl, http/2) + * + * @returns {boolean} + */ +const isSecureDefaults = () => toBool(process.env.NPM_SECURE_DEFAULTS); + /** * @returns {string|null} */ @@ -259,4 +275,17 @@ const useLetsencryptServer = () => { return null; }; -export { isCI, configHas, configGet, isSqlite, isMysql, isPostgres, isDebugMode, getPrivateKey, getPublicKey, useLetsencryptStaging, useLetsencryptServer }; +export { + isCI, + configHas, + configGet, + isSqlite, + isMysql, + isPostgres, + isDebugMode, + isSecureDefaults, + getPrivateKey, + getPublicKey, + useLetsencryptStaging, + useLetsencryptServer, +}; diff --git a/backend/routes/main.js b/backend/routes/main.js index a308ea6179..c4470c5d3b 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -1,5 +1,5 @@ import express from "express"; -import { isCI } from "../lib/config.js"; +import { isCI, isSecureDefaults } from "../lib/config.js"; import errs from "../lib/error.js"; import logRequest from "../lib/express/log-request.js"; import pjson from "../package.json" with { type: "json" }; @@ -38,6 +38,7 @@ router.get("/", async (_, res /*, next*/) => { res.status(200).send({ status: "OK", setup, + secure_defaults: isSecureDefaults(), version: { major: Number.parseInt(version.shift(), 10), minor: Number.parseInt(version.shift(), 10), diff --git a/backend/schema/components/health-object.json b/backend/schema/components/health-object.json index 592ead2ca4..2eac33fbf5 100644 --- a/backend/schema/components/health-object.json +++ b/backend/schema/components/health-object.json @@ -14,6 +14,11 @@ "description": "Whether the initial setup has been completed", "example": true }, + "secure_defaults": { + "type": "boolean", + "description": "Whether new hosts should default to the secure options in the UI", + "example": false + }, "version": { "type": "object", "description": "The version object", diff --git a/backend/schema/paths/get.json b/backend/schema/paths/get.json index 9f6ba2a984..633a60d3bf 100644 --- a/backend/schema/paths/get.json +++ b/backend/schema/paths/get.json @@ -12,6 +12,7 @@ "value": { "status": "OK", "setup": true, + "secure_defaults": false, "version": { "major": 2, "minor": 1, diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index d6b07ec012..f6e8578f7b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -25,6 +25,7 @@ services: DEBUG: "true" DEVELOPMENT: "true" LE_STAGING: "true" + NPM_SECURE_DEFAULTS: "true" # db: # DB_MYSQL_HOST: 'db' # DB_MYSQL_PORT: '3306' diff --git a/frontend/src/api/backend/responseTypes.ts b/frontend/src/api/backend/responseTypes.ts index 2f88ede547..dd7c7a3822 100644 --- a/frontend/src/api/backend/responseTypes.ts +++ b/frontend/src/api/backend/responseTypes.ts @@ -4,6 +4,7 @@ export interface HealthResponse { status: string; version: AppVersion; setup: boolean; + secureDefaults?: boolean; } export interface TokenResponse { diff --git a/frontend/src/components/Form/SSLCertificateField.tsx b/frontend/src/components/Form/SSLCertificateField.tsx index 6ab3ea92c1..fd6169f025 100644 --- a/frontend/src/components/Form/SSLCertificateField.tsx +++ b/frontend/src/components/Form/SSLCertificateField.tsx @@ -33,6 +33,7 @@ interface Props { required?: boolean; allowNew?: boolean; forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields + secureDefaults?: boolean; // enable sslForced + http2Support when a cert is first selected } export function SSLCertificateField({ name = "certificateId", @@ -41,14 +42,17 @@ export function SSLCertificateField({ required, allowNew, forHttp = true, + secureDefaults = false, }: Props) { const { locale } = useLocaleState(); const { isLoading, isError, error, data } = useCertificates(); - const { values, setFieldValue } = useFormikContext(); + const { values, setFieldValue, setFieldTouched } = useFormikContext(); const v: any = values || {}; const handleChange = (newValue: any, _actionMeta: ActionMeta) => { setFieldValue(name, newValue?.value); + // A manual selection (including "None") must never be overridden by auto-matching + setFieldTouched(name, true, false); const { sslForced, http2Support, @@ -65,6 +69,10 @@ export function SSLCertificateField({ hstsEnabled && setFieldValue("hstsEnabled", false); hstsSubdomains && setFieldValue("hstsSubdomains", false); } + if (forHttp && newValue?.value && !v[name] && secureDefaults) { + setFieldValue("sslForced", true); + setFieldValue("http2Support", true); + } if (newValue?.value !== "new") { dnsChallenge && setFieldValue("dnsChallenge", undefined); dnsProvider && setFieldValue("dnsProvider", undefined); @@ -116,7 +124,7 @@ export function SSLCertificateField({