From 187d21a0d5f5145a9f4a2e8b719381ff91ec5bfa Mon Sep 17 00:00:00 2001 From: jerry-yuan Date: Sat, 31 Jan 2026 13:11:47 +0000 Subject: [PATCH 1/6] 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 || {}, From 2b6a61759920d1cc1652ef14a55fcf68aac4d857 Mon Sep 17 00:00:00 2001 From: jerry-yuan Date: Sat, 31 Jan 2026 13:28:53 +0000 Subject: [PATCH 2/6] fix: reformat migration scripts --- .../20260131163528_trust_forwarded_proto.js | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/backend/migrations/20260131163528_trust_forwarded_proto.js b/backend/migrations/20260131163528_trust_forwarded_proto.js index e982dbf7..546cbca6 100644 --- a/backend/migrations/20260131163528_trust_forwarded_proto.js +++ b/backend/migrations/20260131163528_trust_forwarded_proto.js @@ -1,6 +1,6 @@ import { migrate as logger } from "../logger.js"; -const migrateName = "redirect_auto_scheme"; +const migrateName = "trust_forwarded_proto"; /** * Migrate @@ -11,9 +11,15 @@ const migrateName = "redirect_auto_scheme"; * @returns {Promise} */ const up = function (knex) { - return knex.schema.alterTable('proxy_host', (table) => { - table.tinyint('trust_forwarded_proto').notNullable().defaultTo(0); - }); + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .alterTable('proxy_host', (table) => { + table.tinyint('trust_forwarded_proto').notNullable().defaultTo(0); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); }; /** @@ -23,9 +29,15 @@ const up = function (knex) { * @returns {Promise} */ const down = function (knex) { - return knex.schema.alterTable('proxy_host', (table) => { - table.dropColumn('trust_forwarded_proto'); - }); + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .alterTable('proxy_host', (table) => { + table.dropColumn('trust_forwarded_proto'); + }) + .then(() => { + logger.info(`[${migrateName}] proxy_host Table altered`); + }); }; export { up, down }; \ No newline at end of file From 054742539fad428dc5c471dd8361abe625c8ed4d Mon Sep 17 00:00:00 2001 From: jerry-yuan Date: Sat, 31 Jan 2026 14:17:05 +0000 Subject: [PATCH 3/6] fix: Supplement Swagger documentation --- backend/schema/components/proxy-host-object.json | 1 + backend/schema/paths/nginx/proxy-hosts/get.json | 3 ++- backend/schema/paths/nginx/proxy-hosts/hostID/get.json | 1 + backend/schema/paths/nginx/proxy-hosts/hostID/put.json | 1 + backend/schema/paths/nginx/proxy-hosts/post.json | 1 + 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/schema/components/proxy-host-object.json b/backend/schema/components/proxy-host-object.json index cebbe0e6..3ac64621 100644 --- a/backend/schema/components/proxy-host-object.json +++ b/backend/schema/components/proxy-host-object.json @@ -144,6 +144,7 @@ }, "trust_forwarded_proto":{ "type": "boolean", + "description": "Trust the forwarded headers", "example": false }, "certificate": { diff --git a/backend/schema/paths/nginx/proxy-hosts/get.json b/backend/schema/paths/nginx/proxy-hosts/get.json index 7f8cb148..301e28bf 100644 --- a/backend/schema/paths/nginx/proxy-hosts/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/get.json @@ -58,7 +58,8 @@ "enabled": true, "locations": [], "hsts_enabled": false, - "hsts_subdomains": false + "hsts_subdomains": false, + "trust_forwarded_proto": false } ] } diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json index 351451c4..2e677fed 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/get.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/get.json @@ -56,6 +56,7 @@ "locations": [], "hsts_enabled": false, "hsts_subdomains": false, + "trust_forwarded_proto": false, "owner": { "id": 1, "created_on": "2025-10-28T00:50:24.000Z", diff --git a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json index 98b370bc..fc319845 100644 --- a/backend/schema/paths/nginx/proxy-hosts/hostID/put.json +++ b/backend/schema/paths/nginx/proxy-hosts/hostID/put.json @@ -125,6 +125,7 @@ "locations": [], "hsts_enabled": false, "hsts_subdomains": false, + "trust_forwarded_proto": false, "owner": { "id": 1, "created_on": "2025-10-28T00:50:24.000Z", diff --git a/backend/schema/paths/nginx/proxy-hosts/post.json b/backend/schema/paths/nginx/proxy-hosts/post.json index e0763d9d..28ddad8f 100644 --- a/backend/schema/paths/nginx/proxy-hosts/post.json +++ b/backend/schema/paths/nginx/proxy-hosts/post.json @@ -122,6 +122,7 @@ "locations": [], "hsts_enabled": false, "hsts_subdomains": false, + "trust_forwarded_proto": false, "certificate": null, "owner": { "id": 1, From 232b5b759a74f70da12a860da9e1c58ae27774a3 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 1 Feb 2026 00:16:17 +0800 Subject: [PATCH 4/6] fix: make variable name meaningful --- .../rootfs/etc/nginx/conf.d/include/force-ssl.conf | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 000bffe9..24ef18a0 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf @@ -7,20 +7,20 @@ if ($request_uri = /.well-known/acme-challenge/test-challenge) { } # Check if the ssl staff has been handled -set $test_proto ""; +set $test_ssl_handled ""; if ($trust_forwarded_proto = T){ - set $test_proto "${test_proto}T"; + set $test_ssl_handled "${test_ssl_handled}T"; } if ($http_x_forwarded_proto = "https") { - set $test_proto "${test_proto}S"; + set $test_ssl_handled "${test_ssl_handled}S"; } if ($http_x_forwarded_scheme = "https") { - set $test_proto "${test_proto}S"; + set $test_ssl_handled "${test_ssl_handled}S"; } -if ($test_proto = "TSS") { - set $test_proto "TS"; +if ($test_ssl_handled = "TSS") { + set $test_ssl_handled "TS"; } -if ($test_proto = "TS") { +if ($test_ssl_handled = "TS") { set $test "${test}S"; } From 21f63e3db3dbdb558b2490ae84c4af32af4ee801 Mon Sep 17 00:00:00 2001 From: jerry-yuan Date: Sun, 1 Feb 2026 10:38:09 +0000 Subject: [PATCH 5/6] fix: delete advanced options from redir_host/dead_host/streams --- .../src/components/Form/SSLOptionsFields.tsx | 57 ++++++++++--------- frontend/src/modals/ProxyHostModal.tsx | 2 +- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/Form/SSLOptionsFields.tsx b/frontend/src/components/Form/SSLOptionsFields.tsx index a697c5f0..ecf23d26 100644 --- a/frontend/src/components/Form/SSLOptionsFields.tsx +++ b/frontend/src/components/Form/SSLOptionsFields.tsx @@ -5,11 +5,12 @@ import { T } from "src/locale"; interface Props { forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields + forProxyHost?: boolean; // the advanced fields forceDNSForNew?: boolean; requireDomainNames?: boolean; // used for streams color?: string; } -export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) { +export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) { const { values, setFieldValue } = useFormikContext(); const v: any = values || {}; @@ -115,6 +116,34 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain ); + const getHttpAdvancedOptions = () =>( +
+
+ +
+
+ + {({ field }: any) => ( + + )} + +
+
+
+
+ ); + return (
{forHttp ? getHttpOptions() : null} @@ -140,31 +169,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain {dnsChallenge ? : null} ) : null} - {
-
- -
-
- - {({ field }: any) => ( - - )} - -
-
-
-
} + {forProxyHost && forHttp ? getHttpAdvancedOptions() : null}
); } diff --git a/frontend/src/modals/ProxyHostModal.tsx b/frontend/src/modals/ProxyHostModal.tsx index 4a37962c..3227be51 100644 --- a/frontend/src/modals/ProxyHostModal.tsx +++ b/frontend/src/modals/ProxyHostModal.tsx @@ -340,7 +340,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { label="ssl-certificate" allowNew /> - +
From eeab425ea4419c038955433f5ce1c2da75d07eda Mon Sep 17 00:00:00 2001 From: jerry-yuan Date: Tue, 10 Feb 2026 10:52:32 +0000 Subject: [PATCH 6/6] fix: unknown "trust_forwarded_proto" variable error when run with already created old virtual hosts --- docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 24ef18a0..8e58c64a 100644 --- a/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf +++ b/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf @@ -8,7 +8,10 @@ if ($request_uri = /.well-known/acme-challenge/test-challenge) { # Check if the ssl staff has been handled set $test_ssl_handled ""; -if ($trust_forwarded_proto = T){ +if ($trust_forwarded_proto = "") { + set $trust_forwarded_proto "F"; +} +if ($trust_forwarded_proto = "T") { set $test_ssl_handled "${test_ssl_handled}T"; } if ($http_x_forwarded_proto = "https") {