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.
This commit is contained in:
jerry-yuan
2026-01-31 13:11:47 +00:00
parent bad3eac515
commit 187d21a0d5
15 changed files with 120 additions and 4 deletions

View File

@@ -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 };

View File

@@ -21,6 +21,7 @@ const boolFields = [
"enabled", "enabled",
"hsts_enabled", "hsts_enabled",
"hsts_subdomains", "hsts_subdomains",
"trust_forwarded_proto",
]; ];
class ProxyHost extends Model { class ProxyHost extends Model {

View File

@@ -22,7 +22,8 @@
"enabled", "enabled",
"locations", "locations",
"hsts_enabled", "hsts_enabled",
"hsts_subdomains" "hsts_subdomains",
"trust_forwarded_proto"
], ],
"properties": { "properties": {
"id": { "id": {
@@ -141,6 +142,10 @@
"hsts_subdomains": { "hsts_subdomains": {
"$ref": "../common.json#/properties/hsts_subdomains" "$ref": "../common.json#/properties/hsts_subdomains"
}, },
"trust_forwarded_proto":{
"type": "boolean",
"example": false
},
"certificate": { "certificate": {
"oneOf": [ "oneOf": [
{ {

View File

@@ -56,6 +56,9 @@
"hsts_subdomains": { "hsts_subdomains": {
"$ref": "../../../../components/proxy-host-object.json#/properties/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": { "http2_support": {
"$ref": "../../../../components/proxy-host-object.json#/properties/http2_support" "$ref": "../../../../components/proxy-host-object.json#/properties/http2_support"
}, },

View File

@@ -48,6 +48,9 @@
"hsts_subdomains": { "hsts_subdomains": {
"$ref": "../../../components/proxy-host-object.json#/properties/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": { "http2_support": {
"$ref": "../../../components/proxy-host-object.json#/properties/http2_support" "$ref": "../../../components/proxy-host-object.json#/properties/http2_support"
}, },

View File

@@ -1,6 +1,11 @@
{% if certificate and certificate_id > 0 -%} {% if certificate and certificate_id > 0 -%}
{% if ssl_forced == 1 or ssl_forced == true %} {% if ssl_forced == 1 or ssl_forced == true %}
# Force SSL # 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; include conf.d/include/force-ssl.conf;
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@@ -5,9 +5,25 @@ if ($scheme = "http") {
if ($request_uri = /.well-known/acme-challenge/test-challenge) { if ($request_uri = /.well-known/acme-challenge/test-challenge) {
set $test "${test}T"; 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") { 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"; set $test "${test}S";
} }
if ($test = H) { if ($test = H) {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }

View File

@@ -1,7 +1,7 @@
add_header X-Served-By $host; add_header X-Served-By $host;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme;
proxy_set_header X-Forwarded-Proto $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-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_pass $forward_scheme://$server:$port$request_uri; proxy_pass $forward_scheme://$server:$port$request_uri;

View File

@@ -57,6 +57,18 @@ http {
default 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 # Real IP Determination
# Local subnets: # Local subnets:

View File

@@ -127,6 +127,7 @@ export interface ProxyHost {
locations?: ProxyLocation[]; locations?: ProxyLocation[];
hstsEnabled: boolean; hstsEnabled: boolean;
hstsSubdomains: boolean; hstsSubdomains: boolean;
trustForwardedProto: boolean;
// Expansions: // Expansions:
owner?: User; owner?: User;
accessList?: AccessList; accessList?: AccessList;

View File

@@ -15,7 +15,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
const newCertificate = v?.certificateId === "new"; const newCertificate = v?.certificateId === "new";
const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0); 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 || {}; const { dnsChallenge } = meta || {};
if (forceDNSForNew && newCertificate && !dnsChallenge) { if (forceDNSForNew && newCertificate && !dnsChallenge) {
@@ -140,6 +140,31 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
{dnsChallenge ? <DNSProviderFields showBoundaryBox /> : null} {dnsChallenge ? <DNSProviderFields showBoundaryBox /> : null}
</> </>
) : null} ) : null}
{<div>
<details>
<summary className="mb-1"><T id="domains.advanced" /></summary>
<div className="row">
<div className="col-12">
<Field name="trustForwardedProto">
{({ field }: any) => (
<label className="form-check form-switch mt-1">
<input
className={trustForwardedProto ? toggleEnabled : toggleClasses}
type="checkbox"
checked={!!trustForwardedProto}
onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate || !sslForced}
/>
<span className="form-check-label">
<T id="domains.trust-forwarded-proto" />
</span>
</label>
)}
</Field>
</div>
</div>
</details>
</div>}
</div> </div>
); );
} }

View File

@@ -24,6 +24,7 @@ const fetchProxyHost = (id: number | "new") => {
enabled: true, enabled: true,
hstsEnabled: false, hstsEnabled: false,
hstsSubdomains: false, hstsSubdomains: false,
trustForwardedProto: false,
} as ProxyHost); } as ProxyHost);
} }
return getProxyHost(id, ["owner"]); return getProxyHost(id, ["owner"]);

View File

@@ -347,6 +347,9 @@
"domain-names.wildcards-not-supported": { "domain-names.wildcards-not-supported": {
"defaultMessage": "Wildcards not supported for this CA" "defaultMessage": "Wildcards not supported for this CA"
}, },
"domains.advanced": {
"defaultMessage": "Advanced"
},
"domains.force-ssl": { "domains.force-ssl": {
"defaultMessage": "Force SSL" "defaultMessage": "Force SSL"
}, },
@@ -359,6 +362,9 @@
"domains.http2-support": { "domains.http2-support": {
"defaultMessage": "HTTP/2 Support" "defaultMessage": "HTTP/2 Support"
}, },
"domains.trust-forwarded-proto": {
"defaultMessage": "Trust Upstream Forwarded Proto Headers"
},
"domains.use-dns": { "domains.use-dns": {
"defaultMessage": "Use DNS Challenge" "defaultMessage": "Use DNS Challenge"
}, },

View File

@@ -275,6 +275,9 @@
"domain-names.wildcards-not-supported": { "domain-names.wildcards-not-supported": {
"defaultMessage": "此 CA 不支持通配符" "defaultMessage": "此 CA 不支持通配符"
}, },
"domains.advanced": {
"defaultMessage": "高级选项"
},
"domains.force-ssl": { "domains.force-ssl": {
"defaultMessage": "强制 SSL" "defaultMessage": "强制 SSL"
}, },
@@ -287,6 +290,9 @@
"domains.http2-support": { "domains.http2-support": {
"defaultMessage": "HTTP/2 支持" "defaultMessage": "HTTP/2 支持"
}, },
"domains.trust-forwarded-proto": {
"defaultMessage": "信任上游代理传递的协议类型头"
},
"domains.use-dns": { "domains.use-dns": {
"defaultMessage": "使用DNS验证" "defaultMessage": "使用DNS验证"
}, },

View File

@@ -88,6 +88,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
http2Support: data?.http2Support || false, http2Support: data?.http2Support || false,
hstsEnabled: data?.hstsEnabled || false, hstsEnabled: data?.hstsEnabled || false,
hstsSubdomains: data?.hstsSubdomains || false, hstsSubdomains: data?.hstsSubdomains || false,
trustForwardedProto: data?.trustForwardedProto || false,
// Advanced tab // Advanced tab
advancedConfig: data?.advancedConfig || "", advancedConfig: data?.advancedConfig || "",
meta: data?.meta || {}, meta: data?.meta || {},