diff --git a/backend/migrations/20260131163528_trust_forwarded_proto.js b/backend/migrations/20260131163528_trust_forwarded_proto.js new file mode 100644 index 00000000..546cbca6 --- /dev/null +++ b/backend/migrations/20260131163528_trust_forwarded_proto.js @@ -0,0 +1,43 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "trust_forwarded_proto"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = function (knex) { + 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`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = function (knex) { + 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 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..3ac64621 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,11 @@ "hsts_subdomains": { "$ref": "../common.json#/properties/hsts_subdomains" }, + "trust_forwarded_proto":{ + "type": "boolean", + "description": "Trust the forwarded headers", + "example": false + }, "certificate": { "oneOf": [ { 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 7ae60e1a..fc319845 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" }, @@ -122,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 77d772e9..28ddad8f 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" }, @@ -119,6 +122,7 @@ "locations": [], "hsts_enabled": false, "hsts_subdomains": false, + "trust_forwarded_proto": false, "certificate": null, "owner": { "id": 1, 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..8e58c64a 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,28 @@ 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_ssl_handled ""; +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") { + set $test_ssl_handled "${test_ssl_handled}S"; +} +if ($http_x_forwarded_scheme = "https") { + set $test_ssl_handled "${test_ssl_handled}S"; +} +if ($test_ssl_handled = "TSS") { + set $test_ssl_handled "TS"; +} +if ($test_ssl_handled = "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..ecf23d26 100644 --- a/frontend/src/components/Form/SSLOptionsFields.tsx +++ b/frontend/src/components/Form/SSLOptionsFields.tsx @@ -5,17 +5,18 @@ 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 || {}; 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) { @@ -115,6 +116,34 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain ); + const getHttpAdvancedOptions = () =>( +
+
+ +
+
+ + {({ field }: any) => ( + + )} + +
+
+
+
+ ); + return (
{forHttp ? getHttpOptions() : null} @@ -140,6 +169,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain {dnsChallenge ? : null} ) : null} + {forProxyHost && forHttp ? getHttpAdvancedOptions() : null}
); } 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..3227be51 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 || {}, @@ -339,7 +340,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => { label="ssl-certificate" allowNew /> - +