From 187d21a0d5f5145a9f4a2e8b719381ff91ec5bfa Mon Sep 17 00:00:00 2001 From: jerry-yuan Date: Sat, 31 Jan 2026 13:11:47 +0000 Subject: [PATCH] feat: add trust_forwarded_proto option for SSL redirect handling in reverse proxy scenarios When Nginx is behind another proxy server (like CloudFlare or AWS ALB), the force-SSL feature can cause redirect loops because Nginx sees the connection as plain HTTP while SSL is already handled upstream. This adds a new boolean option to trust the X-Forwarded-Proto header from upstream proxies. Changes: - Add `trust_forwarded_proto` column to proxy_host table (migration) - Update model and API schema to support the new boolean field - Modify force-ssl Nginx template to check X-Forwarded-Proto/X-Forwarded-Scheme - Add map directives in nginx.conf to validate and sanitize forwarded headers - Add advanced option toggle in frontend UI with i18n support (EN/ZH) - Set proxy headers from validated map variables instead of $scheme This allows administrators to control SSL redirect behavior when Nginx is deployed behind a TLS-terminating proxy. --- .../20260131163528_trust_forwarded_proto.js | 31 +++++++++++++++++++ backend/models/proxy_host.js | 1 + .../schema/components/proxy-host-object.json | 7 ++++- .../paths/nginx/proxy-hosts/hostID/put.json | 3 ++ .../schema/paths/nginx/proxy-hosts/post.json | 3 ++ backend/templates/_forced_ssl.conf | 5 +++ .../etc/nginx/conf.d/include/force-ssl.conf | 16 ++++++++++ .../etc/nginx/conf.d/include/proxy.conf | 4 +-- docker/rootfs/etc/nginx/nginx.conf | 12 +++++++ frontend/src/api/backend/models.ts | 1 + .../src/components/Form/SSLOptionsFields.tsx | 27 +++++++++++++++- frontend/src/hooks/useProxyHost.ts | 1 + frontend/src/locale/src/en.json | 6 ++++ frontend/src/locale/src/zh.json | 6 ++++ frontend/src/modals/ProxyHostModal.tsx | 1 + 15 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/20260131163528_trust_forwarded_proto.js diff --git a/backend/migrations/20260131163528_trust_forwarded_proto.js b/backend/migrations/20260131163528_trust_forwarded_proto.js new file mode 100644 index 00000000..e982dbf7 --- /dev/null +++ b/backend/migrations/20260131163528_trust_forwarded_proto.js @@ -0,0 +1,31 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "redirect_auto_scheme"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = function (knex) { + return knex.schema.alterTable('proxy_host', (table) => { + table.tinyint('trust_forwarded_proto').notNullable().defaultTo(0); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = function (knex) { + return knex.schema.alterTable('proxy_host', (table) => { + table.dropColumn('trust_forwarded_proto'); + }); +}; + +export { up, down }; \ No newline at end of file diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index b6ce6361..e8f447c8 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -21,6 +21,7 @@ const boolFields = [ "enabled", "hsts_enabled", "hsts_subdomains", + "trust_forwarded_proto", ]; class ProxyHost extends Model { diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index 464b188e..cebbe0e6 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -22,7 +22,8 @@ "enabled", "locations", "hsts_enabled", - "hsts_subdomains" + "hsts_subdomains", + "trust_forwarded_proto" ], "properties": { "id": { @@ -141,6 +142,10 @@ "hsts_subdomains": { "$ref": "../common.json#/properties/hsts_subdomains" }, + "trust_forwarded_proto":{ + "type": "boolean", + "example": false + }, "certificate": { "oneOf": [ { diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index 7ae60e1a..98b370bc 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -56,6 +56,9 @@ "hsts_subdomains": { "$ref": "../../../../components/proxy-host-object.json#/properties/hsts_subdomains" }, + "trust_forwarded_proto": { + "$ref": "../../../../components/proxy-host-object.json#/properties/trust_forwarded_proto" + }, "http2_support": { "$ref": "../../../../components/proxy-host-object.json#/properties/http2_support" }, diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index 77d772e9..e0763d9d 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -48,6 +48,9 @@ "hsts_subdomains": { "$ref": "../../../components/proxy-host-object.json#/properties/hsts_subdomains" }, + "trust_forwarded_proto": { + "$ref": "../../../components/proxy-host-object.json#/properties/trust_forwarded_proto" + }, "http2_support": { "$ref": "../../../components/proxy-host-object.json#/properties/http2_support" }, diff --git a/backend/templates/_forced_ssl.conf b/backend/templates/_forced_ssl.conf index 7fade20c..886e866e 100644 --- a/backend/templates/_forced_ssl.conf +++ b/backend/templates/_forced_ssl.conf @@ -1,6 +1,11 @@ {% if certificate and certificate_id > 0 -%} {% if ssl_forced == 1 or ssl_forced == true %} # Force SSL + {% if trust_forwarded_proto == true %} + set $trust_forwarded_proto "T"; + {% else %} + set $trust_forwarded_proto "F"; + {% endif %} include conf.d/include/force-ssl.conf; {% endif %} {% endif %} \ No newline at end of file diff --git a/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf b/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf index e43c2fc8..000bffe9 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf @@ -5,9 +5,25 @@ if ($scheme = "http") { if ($request_uri = /.well-known/acme-challenge/test-challenge) { set $test "${test}T"; } + +# Check if the ssl staff has been handled +set $test_proto ""; +if ($trust_forwarded_proto = T){ + set $test_proto "${test_proto}T"; +} if ($http_x_forwarded_proto = "https") { + set $test_proto "${test_proto}S"; +} +if ($http_x_forwarded_scheme = "https") { + set $test_proto "${test_proto}S"; +} +if ($test_proto = "TSS") { + set $test_proto "TS"; +} +if ($test_proto = "TS") { set $test "${test}S"; } + if ($test = H) { return 301 https://$host$request_uri; } diff --git a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf index d346c4ef..fe2c2f21 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/proxy.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/proxy.conf @@ -1,7 +1,7 @@ add_header X-Served-By $host; proxy_set_header Host $host; -proxy_set_header X-Forwarded-Scheme $scheme; -proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme; +proxy_set_header X-Forwarded-Proto $x_forwarded_proto; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; proxy_pass $forward_scheme://$server:$port$request_uri; diff --git a/docker/rootfs/etc/nginx/nginx.conf b/docker/rootfs/etc/nginx/nginx.conf index 0a83ef0c..892cf158 100644 --- a/docker/rootfs/etc/nginx/nginx.conf +++ b/docker/rootfs/etc/nginx/nginx.conf @@ -57,6 +57,18 @@ http { default http; } + # Handle upstream X-Forwarded-Proto and X-Forwarded-Scheme header + map $http_x_forwarded_proto $x_forwarded_proto { + "http" "http"; + "https" "https"; + default $scheme; + } + map $http_x_forwarded_scheme $x_forwarded_scheme { + "http" "http"; + "https" "https"; + default $scheme; + } + # Real IP Determination # Local subnets: diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index d63d47ae..2ae0b083 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -127,6 +127,7 @@ export interface ProxyHost { locations?: ProxyLocation[]; hstsEnabled: boolean; hstsSubdomains: boolean; + trustForwardedProto: boolean; // Expansions: owner?: User; accessList?: AccessList; diff --git a/frontend/src/components/Form/SSLOptionsFields.tsx b/frontend/src/components/Form/SSLOptionsFields.tsx index 1c5b9f9a..a697c5f0 100644 --- a/frontend/src/components/Form/SSLOptionsFields.tsx +++ b/frontend/src/components/Form/SSLOptionsFields.tsx @@ -15,7 +15,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain const newCertificate = v?.certificateId === "new"; const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0); - const { sslForced, http2Support, hstsEnabled, hstsSubdomains, meta } = v; + const { sslForced, http2Support, hstsEnabled, hstsSubdomains, trustForwardedProto, meta } = v; const { dnsChallenge } = meta || {}; if (forceDNSForNew && newCertificate && !dnsChallenge) { @@ -140,6 +140,31 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain {dnsChallenge ? : null} ) : null} + {
+
+ +
+
+ + {({ field }: any) => ( + + )} + +
+
+
+
} ); } diff --git a/frontend/src/hooks/useProxyHost.ts b/frontend/src/hooks/useProxyHost.ts index e6a2adea..24e7f4fa 100644 --- a/frontend/src/hooks/useProxyHost.ts +++ b/frontend/src/hooks/useProxyHost.ts @@ -24,6 +24,7 @@ const fetchProxyHost = (id: number | "new") => { enabled: true, hstsEnabled: false, hstsSubdomains: false, + trustForwardedProto: false, } as ProxyHost); } return getProxyHost(id, ["owner"]); diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index ae02605e..bb00ac33 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -347,6 +347,9 @@ "domain-names.wildcards-not-supported": { "defaultMessage": "Wildcards not supported for this CA" }, + "domains.advanced": { + "defaultMessage": "Advanced" + }, "domains.force-ssl": { "defaultMessage": "Force SSL" }, @@ -359,6 +362,9 @@ "domains.http2-support": { "defaultMessage": "HTTP/2 Support" }, + "domains.trust-forwarded-proto": { + "defaultMessage": "Trust Upstream Forwarded Proto Headers" + }, "domains.use-dns": { "defaultMessage": "Use DNS Challenge" }, diff --git a/frontend/src/locale/src/zh.json b/frontend/src/locale/src/zh.json index d40ea39d..72494bb6 100644 --- a/frontend/src/locale/src/zh.json +++ b/frontend/src/locale/src/zh.json @@ -275,6 +275,9 @@ "domain-names.wildcards-not-supported": { "defaultMessage": "此 CA 不支持通配符" }, + "domains.advanced": { + "defaultMessage": "高级选项" + }, "domains.force-ssl": { "defaultMessage": "强制 SSL" }, @@ -287,6 +290,9 @@ "domains.http2-support": { "defaultMessage": "HTTP/2 支持" }, + "domains.trust-forwarded-proto": { + "defaultMessage": "信任上游代理传递的协议类型头" + }, "domains.use-dns": { "defaultMessage": "使用DNS验证" }, diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index ca322849..4a37962c 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -88,6 +88,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { http2Support: data?.http2Support || false, hstsEnabled: data?.hstsEnabled || false, hstsSubdomains: data?.hstsSubdomains || false, + trustForwardedProto: data?.trustForwardedProto || false, // Advanced tab advancedConfig: data?.advancedConfig || "", meta: data?.meta || {},