diff --git a/CHANGELOG.md b/CHANGELOG.md index b7052049..320ba697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,57 @@ ## Breaking Changes +## Changes since v7.15.2 + +# V7.15.2 + +## Release Highlights + +- 🔵 Golang version upgrade to v1.25.9 + - Upgrade of all dependencies to their latest versions + - [CVE-2026-34986](https://nvd.nist.gov/vuln/detail/CVE-2026-34986) + - [CVE-2026-32281](https://nvd.nist.gov/vuln/detail/CVE-2026-32281) + - [CVE-2026-32289](https://nvd.nist.gov/vuln/detail/CVE-2026-32289) + - [CVE-2026-32288](https://nvd.nist.gov/vuln/detail/CVE-2026-32288) + - [CVE-2026-32280](https://nvd.nist.gov/vuln/detail/CVE-2026-32280) + - [CVE-2026-32282](https://nvd.nist.gov/vuln/detail/CVE-2026-32282) + - [CVE-2026-32283](https://nvd.nist.gov/vuln/detail/CVE-2026-32283) +- 🕵️‍♀️ Vulnerabilities have been addressed + +## Important Notes + +We have had security audits performed on OAuth2 Proxy in the past couple of weeks and as a result we have fixed +several CRITICAL vulnerabilities. + +The security vulnerabilities include multiple authentication bypasses and a potential session fixation attack. +For more details and to identify if you are effects, we urge all users of OAuth2 Proxy to read the security +disclosures. + +- (Critical) [GHSA-5hvv-m4w4-gf6v](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-5hvv-m4w4-gf6v) fix: health check user-agent authentication bypass +- (Critical) [GHSA-7x63-xv5r-3p2x](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-7x63-xv5r-3p2x) fix: authentication bypass via X-Forwarded-Uri header spoofing +- (High) [GHSA-pxq7-h93f-9jrg](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-pxq7-h93f-9jrg) fix: fragment evaluation as part of the allowed routes +- (Moderate) [GHSA-c5c4-8r6x-56w3](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-c5c4-8r6x-56w3) fix: email validation bypass via malformed multi-@ email claims + +Furthermore, for improving the security of OAuth2 Proxy we introduced a new flag `--trusted-proxy-ip` that allows users +to explicitly specify trusted reverse proxy IPs for the `X-Forwarded-*` headers. This is an important step to prevent +potential header spoofing attacks and to ensure that OAuth2 Proxy only trusts headers from known and trusted sources. +We highly recommend users to review their deployment architecture and consider using this flag to enhance the security +of their OAuth2 Proxy instances. Check the docs for more details: https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview#proxy-options + +Furthermore, we want to thank everyone who contributed to the audits and reported potential issues to make open source +software like OAuth2 Proxy more secure for everyone. + +## Breaking Changes + ## Changes since v7.15.1 -- [#3399](https://github.com/oauth2-proxy/oauth2-proxy/pull/3399) feat: add Redis client certificate and key paths for mutual TLS to the Redis session store (@karatkep) +- [#3411](https://github.com/oauth2-proxy/oauth2-proxy/pull/3411) chore(deps): update gomod dependencies (@tuunit) +- [#3333](https://github.com/oauth2-proxy/oauth2-proxy/pull/3333) fix: invalidate session on fatal OAuth2 refresh errors (@frhack) +- [GHSA-f24x-5g9q-753f](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-f24x-5g9q-753f) fix: clear session cookie at beginning of signinpage handler (@fnoehWM / @bella-WI / @tuunit) +- [GHSA-5hvv-m4w4-gf6v](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-5hvv-m4w4-gf6v) fix: health check user-agent authentication bypass (@tuunit) +- [GHSA-7x63-xv5r-3p2x](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-7x63-xv5r-3p2x) fix: authentication bypass via X-Forwarded-Uri header spoofing (@tuunit) +- [GHSA-pxq7-h93f-9jrg](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-pxq7-h93f-9jrg) fix: fragment evaluation as part of the allowed routes (@tuunit) +- [GHSA-c5c4-8r6x-56w3](https://github.com/oauth2-proxy/oauth2-proxy/security/advisories/GHSA-c5c4-8r6x-56w3) fix: email validation bypass via malformed multi-@ email claims (@tuunit) # V7.15.1 diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 71ec0fdc..2b98816c 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -6,7 +6,7 @@ by our [project governance](GOVERNANCE.md). | Name | GitHub Handle | Domains of reponsibility | Email Alias | Affiliation | | ---------------- | ------------------------------------------------------ | ------------------------ | -------------------------- | ----------- | | Joel Speed | [@JoelSpeed](https://github.com/joelspeed) | Governance, Core | joel@oauth2-proxy.dev | Red Hat | -| Jan Larwig | [@tuunit](https://github.com/tuunit) | Governance, Core | jan@oauth2-proxy.dev | IONOS Cloud | +| Jan Larwig | [@tuunit](https://github.com/tuunit) | Governance, Core | jan@oauth2-proxy.dev | STACKIT | | JJ Łakis | [@jjlakis](https://github.com/jjlakis) | Provider | jj@oauth2-proxy.dev | - | | Koen van Zuijlen | [@kvanzuijlen](https://github.com/kvanzuijlen) | CI | koen@oauth2-proxy.dev | - | | Pierluigi Lenoci | [@pierluigilenoci](https://github.com/pierluigilenoci) | Helm | pierluigi@oauth2-proxy.dev | SAP | diff --git a/contrib/local-environment/dex.yaml b/contrib/local-environment/dex.yaml index f0a2ead4..e3ed0f8f 100644 --- a/contrib/local-environment/dex.yaml +++ b/contrib/local-environment/dex.yaml @@ -6,7 +6,7 @@ storage: type: etcd config: endpoints: - - http://etcd:2379 + - http://etcd:2379 namespace: dex/ web: http: 0.0.0.0:5556 @@ -16,17 +16,18 @@ expiry: signingKeys: "4h" idTokens: "1h" staticClients: -- id: oauth2-proxy - redirectURIs: - # These redirect URIs point to the `--redirect-url` for OAuth2 proxy. - - 'http://oauth2-proxy.localtest.me:4180/oauth2/callback' # For basic proxy example. - - 'http://oauth2-proxy.oauth2-proxy.localhost/oauth2/callback' # For nginx and traefik example. - name: 'OAuth2 Proxy' - secret: b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK + - id: oauth2-proxy + redirectURIs: + # These redirect URIs point to the `--redirect-url` for OAuth2 proxy. + - "http://oauth2-proxy.localtest.me:4180/oauth2/callback" # For basic proxy example. + - "http://oauth2-proxy.localtest.me:8080/oauth2/callback" # For nginx example. + - "http://oauth2-proxy.oauth2-proxy.localhost/oauth2/callback" # For traefik example. + name: "OAuth2 Proxy" + secret: b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK enablePasswordDB: true staticPasswords: -- email: "admin@example.com" - # bcrypt hash of the string "password" - hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" - username: "admin" - userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" + - email: "admin@example.com" + # bcrypt hash of the string "password" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "admin" + userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" diff --git a/contrib/local-environment/docker-compose-alpha-config.yaml b/contrib/local-environment/docker-compose-alpha-config.yaml index 515c42e0..2dde7345 100644 --- a/contrib/local-environment/docker-compose-alpha-config.yaml +++ b/contrib/local-environment/docker-compose-alpha-config.yaml @@ -14,7 +14,7 @@ version: "3.0" services: oauth2-proxy: container_name: oauth2-proxy - image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.1 + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2 command: --config /oauth2-proxy.cfg --alpha-config /oauth2-proxy-alpha-config.yaml hostname: oauth2-proxy volumes: diff --git a/contrib/local-environment/docker-compose-gitea.yaml b/contrib/local-environment/docker-compose-gitea.yaml index 3e57ef2d..17d707fb 100644 --- a/contrib/local-environment/docker-compose-gitea.yaml +++ b/contrib/local-environment/docker-compose-gitea.yaml @@ -14,7 +14,7 @@ version: '3.0' services: oauth2-proxy: container_name: oauth2-proxy - image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.1 + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2 command: --config /oauth2-proxy.cfg hostname: oauth2-proxy volumes: diff --git a/contrib/local-environment/docker-compose-keycloak.yaml b/contrib/local-environment/docker-compose-keycloak.yaml index ba3db49a..70d2042b 100644 --- a/contrib/local-environment/docker-compose-keycloak.yaml +++ b/contrib/local-environment/docker-compose-keycloak.yaml @@ -10,11 +10,11 @@ # # Access http://oauth2-proxy.localtest.me:4180 to initiate a login cycle using user=admin@example.com, password=password # Access http://keycloak.localtest.me:9080 with the same credentials to check out the settings -version: '3.0' +version: "3.0" services: oauth2-proxy: container_name: oauth2-proxy - image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.1 + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2 command: --config /oauth2-proxy.cfg hostname: oauth2-proxy volumes: @@ -43,9 +43,9 @@ services: image: keycloak/keycloak:25.0 hostname: keycloak command: - - 'start-dev' - - '--http-port=9080' - - '--import-realm' + - "start-dev" + - "--http-port=9080" + - "--import-realm" volumes: - ./keycloak:/opt/keycloak/data/import environment: diff --git a/contrib/local-environment/docker-compose-nginx.yaml b/contrib/local-environment/docker-compose-nginx.yaml index ed93d57c..2aa403ec 100644 --- a/contrib/local-environment/docker-compose-nginx.yaml +++ b/contrib/local-environment/docker-compose-nginx.yaml @@ -22,11 +22,12 @@ version: "3.0" services: oauth2-proxy: - image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.1 + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2 ports: [] hostname: oauth2-proxy container_name: oauth2-proxy command: --config /oauth2-proxy.cfg + restart: unless-stopped volumes: - "./oauth2-proxy-nginx.cfg:/oauth2-proxy.cfg" networks: @@ -44,7 +45,7 @@ services: image: nginx:1.29 restart: unless-stopped ports: - - 80:80/tcp + - 8080:8080/tcp hostname: nginx volumes: - "./nginx.conf:/etc/nginx/conf.d/default.conf" diff --git a/contrib/local-environment/docker-compose-traefik.yaml b/contrib/local-environment/docker-compose-traefik.yaml index 94d9239b..302f1a42 100644 --- a/contrib/local-environment/docker-compose-traefik.yaml +++ b/contrib/local-environment/docker-compose-traefik.yaml @@ -23,7 +23,7 @@ version: '3.0' services: oauth2-proxy: - image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.1 + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2 ports: [] hostname: oauth2-proxy volumes: diff --git a/contrib/local-environment/docker-compose.yaml b/contrib/local-environment/docker-compose.yaml index 4832eb92..7630167d 100644 --- a/contrib/local-environment/docker-compose.yaml +++ b/contrib/local-environment/docker-compose.yaml @@ -13,7 +13,7 @@ version: "3.0" services: oauth2-proxy: container_name: oauth2-proxy - image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.1 + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2 command: --config /oauth2-proxy.cfg hostname: oauth2-proxy volumes: diff --git a/contrib/local-environment/nginx.conf b/contrib/local-environment/nginx.conf index 15005bf6..0e7bf7b4 100644 --- a/contrib/local-environment/nginx.conf +++ b/contrib/local-environment/nginx.conf @@ -1,91 +1,44 @@ -# Reverse proxy to oauth2-proxy -server { - listen 80; - server_name oauth2-proxy.oauth2-proxy.localhost; - - location / { - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - - proxy_pass http://oauth2-proxy:4180/; - } -} - # Reverse proxy to httpbin server { - listen 80; - server_name httpbin.oauth2-proxy.localhost; + listen 8080; + server_name oauth2-proxy.localtest.me; - auth_request /internal-auth/oauth2/auth; + location /oauth2/ { + proxy_pass http://oauth2-proxy:4180; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Uri $request_uri; + proxy_set_header X-Auth-Request-Redirect $request_uri; + } - # On 401, redirect to the sign_in page via a named location - # This ensures a proper 302 redirect that browsers will follow - error_page 401 = @oauth2_signin; + location = /oauth2/auth { + proxy_pass http://oauth2-proxy:4180; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Uri $request_uri; + # nginx auth_request includes headers but not body + proxy_set_header Content-Length ""; + proxy_pass_request_body off; + } location / { - proxy_pass http://httpbin/; - } - - # Named location for OAuth2 sign-in redirect - # Returns a proper 302 that works with --skip-provider-button - location @oauth2_signin { - return 302 http://oauth2-proxy.oauth2-proxy.localhost/oauth2/sign_in?rd=$scheme://$host$request_uri; - } - - # auth_request must be a URI so this allows an internal path to then proxy to - # the real auth_request path. - # The trailing /'s are required so that nginx strips the prefix before proxying. - location /internal-auth/ { - internal; # Ensure external users can't access this path - - # Make sure the OAuth2 Proxy knows where the original request came from. - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Uri $request_uri; - - proxy_pass http://oauth2-proxy:4180/; - } -} - -# Statically serve the nginx welcome -server { - listen 80; - server_name oauth2-proxy.localhost; - - location / { - auth_request /internal-auth/oauth2/auth; - - # On 401, redirect to the sign_in page via a named location - # This ensures a proper 302 redirect that browsers will follow + auth_request /oauth2/auth; error_page 401 = @oauth2_signin; - root /usr/share/nginx/html; - index index.html index.htm; + # pass information via X-User and X-Email headers to backend, + # requires running with --set-xauthrequest flag + auth_request_set $user $upstream_http_x_auth_request_user; + auth_request_set $email $upstream_http_x_auth_request_email; + proxy_set_header X-User $user; + proxy_set_header X-Email $email; + + proxy_pass http://httpbin/; + # or "root /path/to/site;" or "fastcgi_pass ..." etc } - # Named location for OAuth2 sign-in redirect - # Returns a proper 302 that works with --skip-provider-button + # Named location for handling OAuth2 sign-in redirects + # This ensures the browser receives a proper 302 redirect that it will follow location @oauth2_signin { - return 302 http://oauth2-proxy.oauth2-proxy.localhost/oauth2/sign_in?rd=$scheme://$host$request_uri; - } - - # redirect server error pages to the static page /50x.html - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } - - # auth_request must be a URI so this allows an internal path to then proxy to - # the real auth_request path. - # The trailing /'s are required so that nginx strips the prefix before proxying. - location /internal-auth/ { - internal; # Ensure external users can't access this path - - # Make sure the OAuth2 Proxy knows where the original request came from. - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Uri $request_uri; - - proxy_pass http://oauth2-proxy:4180/; + return 302 /oauth2/sign_in?rd=$scheme://$http_host$request_uri; } } diff --git a/contrib/local-environment/oauth2-proxy-nginx.cfg b/contrib/local-environment/oauth2-proxy-nginx.cfg index 01b64a55..2565c226 100644 --- a/contrib/local-environment/oauth2-proxy-nginx.cfg +++ b/contrib/local-environment/oauth2-proxy-nginx.cfg @@ -1,14 +1,19 @@ http_address="0.0.0.0:4180" cookie_secret="OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" -provider="oidc" email_domains="example.com" -oidc_issuer_url="http://dex.localtest.me:5556/dex" +cookie_secure="false" +upstreams="static://200" +cookie_domains=[".localtest.me"] # Required so cookie can be read on all subdomains. +whitelist_domains=[".localtest.me"] # Required to allow redirection back to original requested target. + +# dex provider client_secret="b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK" client_id="oauth2-proxy" -cookie_secure="false" +redirect_url="http://oauth2-proxy.localtest.me:8080/oauth2/callback" + +oidc_issuer_url="http://dex.localtest.me:5556/dex" +provider="oidc" +provider_display_name="Dex" -redirect_url="http://oauth2-proxy.oauth2-proxy.localhost/oauth2/callback" -cookie_domains=".oauth2-proxy.localhost" # Required so cookie can be read on all subdomains. -whitelist_domains=".oauth2-proxy.localhost" # Required to allow redirection back to original requested target. # Enables the use of `X-Forwarded-*` headers to determine request correctly reverse_proxy="true" diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index a73e3acd..965953fa 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -193,6 +193,10 @@ Provider specific options can be found on their respective subpages. ### Proxy Options +:::warning +When `--reverse-proxy` is enabled, configure `--trusted-proxy-ip` to the IPs or CIDR ranges of the reverse proxies that are allowed to send `X-Forwarded-*` headers. If you leave it unset, OAuth2 Proxy currently trusts all source IPs for backwards compatibility, which means a client that can reach OAuth2 Proxy directly may be able to spoof forwarded headers. +::: + | Flag / Config Field | Type | Description | Default | | ----------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | flag: `--allow-query-semicolons`
toml: `allow_query_semicolons` | bool | allow the use of semicolons in query args ([required for some legacy applications](https://github.com/golang/go/issues/25192)) | `false` | @@ -211,10 +215,11 @@ Provider specific options can be found on their respective subpages. | flag: `--redirect-url`
toml: `redirect_url` | string | the OAuth Redirect URL, e.g. `"https://internalapp.yourcompany.com/oauth2/callback"` | | | flag: `--relative-redirect-url`
toml: `relative_redirect_url` | bool | allow relative OAuth Redirect URL.` | false | | flag: `--reverse-proxy`
toml: `reverse_proxy` | bool | are we running behind a reverse proxy, controls whether headers like X-Real-IP are accepted and allows X-Forwarded-\{Proto,Host,Uri\} headers to be used on redirect selection | false | +| flag: `--trusted-proxy-ip`
toml: `trusted_proxy_ips` | string \| list | list of IPs or CIDR ranges allowed to supply `X-Forwarded-*` headers when `--reverse-proxy` is enabled. If not set, OAuth2 Proxy preserves backwards compatibility by trusting all source IPs (`0.0.0.0/0`, `::/0`) and logs a warning at startup. Configure this to your reverse proxy addresses to prevent forwarded header spoofing. | `"0.0.0.0/0", "::/0"` | | flag: `--signature-key`
toml: `signature_key` | string | GAP-Signature request signature key (algorithm:secretkey) | | | flag: `--skip-auth-preflight`
toml: `skip_auth_preflight` | bool | will skip authentication for OPTIONS requests | false | -| flag: `--skip-auth-regex`
toml: `skip_auth_regex` | string \| list | (DEPRECATED for `--skip-auth-route`) bypass authentication for requests paths that match (may be given multiple times) | | -| flag: `--skip-auth-route`
toml: `skip_auth_routes` | string \| list | bypass authentication for requests that match the method & path. Format: method=path_regex OR method!=path_regex. For all methods: path_regex OR !=path_regex | | +| flag: `--skip-auth-regex`
toml: `skip_auth_regex` | string \| list | (DEPRECATED for `--skip-auth-route`) bypass authentication for requests paths that match (may be given multiple times). Path matching is performed against the normalized path only; fragment identifiers (`#`) and their URL-encoded form (`%23`) are stripped before evaluation. | | +| flag: `--skip-auth-route`
toml: `skip_auth_routes` | string \| list | bypass authentication for requests that match the method & path. Format: method=path_regex OR method!=path_regex. For all methods: path_regex OR !=path_regex. Path matching is performed against the normalized path only; fragment identifiers (`#`) and their URL-encoded form (`%23`) are stripped before evaluation. | | | flag: `--skip-jwt-bearer-tokens`
toml: `skip_jwt_bearer_tokens` | bool | will skip requests that have verified JWT bearer tokens (the token must have [`aud`](https://en.wikipedia.org/wiki/JSON_Web_Token#Standard_fields) that matches this client id or one of the extras from `extra-jwt-issuers`) | false | | flag: `--skip-provider-button`
toml: `skip_provider_button` | bool | will skip sign-in-page to directly reach the next step: oauth/start | false | | flag: `--ssl-insecure-skip-verify`
toml: `ssl_insecure_skip_verify` | bool | skip validation of certificates presented when using HTTPS providers | false | diff --git a/docs/docs/installation.md b/docs/docs/installation.md index d329bd55..7898f70c 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -5,7 +5,7 @@ title: Installation 1. Choose how to deploy: - a. Using a [Prebuilt Binary](https://github.com/oauth2-proxy/oauth2-proxy/releases) (current release is `v7.15.1`) + a. Using a [Prebuilt Binary](https://github.com/oauth2-proxy/oauth2-proxy/releases) (current release is `v7.15.2`) b. Using Go to install the latest release ```bash diff --git a/docs/package.json b/docs/package.json index a288a213..0bb4b494 100644 --- a/docs/package.json +++ b/docs/package.json @@ -42,5 +42,8 @@ }, "engines": { "node": ">=18.0" + }, + "overrides" : { + "webpackbar" : "^7.0.0" } } diff --git a/docs/versioned_docs/version-7.15.x/configuration/overview.md b/docs/versioned_docs/version-7.15.x/configuration/overview.md index a73e3acd..b145065a 100644 --- a/docs/versioned_docs/version-7.15.x/configuration/overview.md +++ b/docs/versioned_docs/version-7.15.x/configuration/overview.md @@ -193,6 +193,10 @@ Provider specific options can be found on their respective subpages. ### Proxy Options +:::warning +When `--reverse-proxy` is enabled, configure `--trusted-proxy-ip` to the IPs or CIDR ranges of the reverse proxies that are allowed to send `X-Forwarded-*` headers. If you leave it unset, OAuth2 Proxy currently trusts all source IPs for backwards compatibility, which means a client that can reach OAuth2 Proxy directly may be able to spoof forwarded headers. +::: + | Flag / Config Field | Type | Description | Default | | ----------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | flag: `--allow-query-semicolons`
toml: `allow_query_semicolons` | bool | allow the use of semicolons in query args ([required for some legacy applications](https://github.com/golang/go/issues/25192)) | `false` | @@ -211,6 +215,7 @@ Provider specific options can be found on their respective subpages. | flag: `--redirect-url`
toml: `redirect_url` | string | the OAuth Redirect URL, e.g. `"https://internalapp.yourcompany.com/oauth2/callback"` | | | flag: `--relative-redirect-url`
toml: `relative_redirect_url` | bool | allow relative OAuth Redirect URL.` | false | | flag: `--reverse-proxy`
toml: `reverse_proxy` | bool | are we running behind a reverse proxy, controls whether headers like X-Real-IP are accepted and allows X-Forwarded-\{Proto,Host,Uri\} headers to be used on redirect selection | false | +| flag: `--trusted-proxy-ip`
toml: `trusted_proxy_ips` | string \| list | list of IPs or CIDR ranges allowed to supply `X-Forwarded-*` headers when `--reverse-proxy` is enabled. If not set, OAuth2 Proxy preserves backwards compatibility by trusting all source IPs (`0.0.0.0/0`, `::/0`) and logs a warning at startup. Configure this to your reverse proxy addresses to prevent forwarded header spoofing. | `"0.0.0.0/0", "::/0"` | | flag: `--signature-key`
toml: `signature_key` | string | GAP-Signature request signature key (algorithm:secretkey) | | | flag: `--skip-auth-preflight`
toml: `skip_auth_preflight` | bool | will skip authentication for OPTIONS requests | false | | flag: `--skip-auth-regex`
toml: `skip_auth_regex` | string \| list | (DEPRECATED for `--skip-auth-route`) bypass authentication for requests paths that match (may be given multiple times) | | diff --git a/docs/versioned_docs/version-7.15.x/installation.md b/docs/versioned_docs/version-7.15.x/installation.md index d329bd55..7898f70c 100644 --- a/docs/versioned_docs/version-7.15.x/installation.md +++ b/docs/versioned_docs/version-7.15.x/installation.md @@ -5,7 +5,7 @@ title: Installation 1. Choose how to deploy: - a. Using a [Prebuilt Binary](https://github.com/oauth2-proxy/oauth2-proxy/releases) (current release is `v7.15.1`) + a. Using a [Prebuilt Binary](https://github.com/oauth2-proxy/oauth2-proxy/releases) (current release is `v7.15.2`) b. Using Go to install the latest release ```bash diff --git a/go.mod b/go.mod index d45e4b6d..ade9c4e8 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,12 @@ require ( github.com/alicebob/miniredis/v2 v2.37.0 github.com/bitly/go-simplejson v0.5.1 github.com/bsm/redislock v0.9.4 - github.com/coreos/go-oidc/v3 v3.17.0 + github.com/coreos/go-oidc/v3 v3.18.0 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/fsnotify/fsnotify v1.9.0 - github.com/go-jose/go-jose/v3 v3.0.4 + github.com/go-jose/go-jose/v3 v3.0.5 github.com/go-jose/go-jose/v4 v4.1.4 - github.com/go-viper/mapstructure/v2 v2.4.0 + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 @@ -32,17 +32,17 @@ require ( github.com/stretchr/testify v1.11.1 github.com/vmihailenco/msgpack/v5 v5.4.1 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/crypto v0.49.0 - golang.org/x/net v0.52.0 + golang.org/x/crypto v0.50.0 + golang.org/x/net v0.53.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 - google.golang.org/api v0.272.0 + google.golang.org/api v0.275.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 k8s.io/apimachinery v0.35.3 ) require ( - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -53,13 +53,13 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect @@ -70,18 +70,18 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.43.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/grpc v1.79.3 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ea2aab21..622f2dac 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -33,6 +35,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -54,6 +58,8 @@ github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01 github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -65,6 +71,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -78,6 +86,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -87,6 +97,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -119,6 +131,8 @@ github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -175,18 +189,29 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -200,10 +225,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -211,6 +240,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -229,6 +260,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -242,6 +275,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -250,19 +285,30 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI= +google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main_test.go b/main_test.go index c7c7057d..58b8ae7e 100644 --- a/main_test.go +++ b/main_test.go @@ -278,7 +278,7 @@ redirect_url="http://localhost:4180/oauth2/callback" Entry("with bad legacy configuration", loadConfigurationTableInput{ configContent: testCoreConfig + "unknown_field=\"something\"", expectedOptions: func() *options.Options { return nil }, - expectedErr: errors.New("failed to load legacy options: failed to load config: error unmarshalling config: decoding failed due to the following error(s):\n\n'' has invalid keys: unknown_field"), + expectedErr: errors.New("failed to load legacy options: failed to load config: error unmarshalling config: decoding failed due to the following error(s):\n\n'options.LegacyOptions' has invalid keys: unknown_field"), }), Entry("with bad alpha configuration", loadConfigurationTableInput{ configContent: testCoreConfig, @@ -290,7 +290,7 @@ redirect_url="http://localhost:4180/oauth2/callback" configContent: testCoreConfig + "unknown_field=\"something\"", alphaConfigContent: testAlphaConfig, expectedOptions: func() *options.Options { return nil }, - expectedErr: errors.New("failed to load legacy options: failed to load config: error unmarshalling config: decoding failed due to the following error(s):\n\n'' has invalid keys: unknown_field"), + expectedErr: errors.New("failed to load legacy options: failed to load config: error unmarshalling config: decoding failed due to the following error(s):\n\n'options.LegacyOptions' has invalid keys: unknown_field"), }), ) diff --git a/oauthproxy.go b/oauthproxy.go index 3efe66fd..e2357c8d 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -59,6 +59,8 @@ const ( ) var ( + defaultTrustedProxyIPs = []string{"0.0.0.0/0", "::/0"} + // ErrNeedsLogin means the user should be redirected to the login page ErrNeedsLogin = errors.New("redirect to login page") @@ -183,13 +185,14 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr logger.Printf("Cookie settings: name:%s secure(https):%v httponly:%v expiry:%s domains:%s path:%s samesite:%s refresh:%s", opts.Cookie.Name, opts.Cookie.Secure, opts.Cookie.HTTPOnly, opts.Cookie.Expire, strings.Join(opts.Cookie.Domains, ","), opts.Cookie.Path, opts.Cookie.SameSite, refresh) - trustedIPs := ip.NewNetSet() - for _, ipStr := range opts.TrustedIPs { - if ipNet := ip.ParseIPNet(ipStr); ipNet != nil { - trustedIPs.AddIPNet(*ipNet) - } else { - return nil, fmt.Errorf("could not parse IP network (%s)", ipStr) - } + trustedIPs, err := ip.ParseNetSet(opts.TrustedIPs) + if err != nil { + return nil, err + } + + trustedProxies, err := buildTrustedProxyNetSet(opts) + if err != nil { + return nil, err } allowedRoutes, err := buildRoutesAllowlist(opts) @@ -202,7 +205,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr return nil, err } - preAuthChain, err := buildPreAuthChain(opts, sessionStore) + preAuthChain, err := buildPreAuthChain(opts, sessionStore, trustedProxies) if err != nil { return nil, fmt.Errorf("could not build pre-auth chain: %v", err) } @@ -355,8 +358,8 @@ func (p *OAuthProxy) buildProxySubrouter(s *mux.Router) { // buildPreAuthChain constructs a chain that should process every request before // the OAuth2 Proxy authentication logic kicks in. // For example forcing HTTPS or health checks. -func buildPreAuthChain(opts *options.Options, sessionStore sessionsapi.SessionStore) (alice.Chain, error) { - chain := alice.New(middleware.NewScope(opts.ReverseProxy, opts.Logging.RequestIDHeader)) +func buildPreAuthChain(opts *options.Options, sessionStore sessionsapi.SessionStore, trustedProxies *ip.NetSet) (alice.Chain, error) { + chain := alice.New(middleware.NewScope(opts.ReverseProxy, opts.Logging.RequestIDHeader, trustedProxies)) if opts.ForceHTTPS { _, httpsPort, err := net.SplitHostPort(opts.Server.SecureBindAddress) @@ -395,6 +398,16 @@ func buildPreAuthChain(opts *options.Options, sessionStore sessionsapi.SessionSt return chain, nil } +func buildTrustedProxyNetSet(opts *options.Options) (*ip.NetSet, error) { + trustedProxyIPs := opts.TrustedProxyIPs + if opts.ReverseProxy && len(trustedProxyIPs) == 0 { + logger.Print("WARNING: --reverse-proxy is enabled but no --trusted-proxy-ip CIDRs were configured. All connecting IPs are trusted to supply X-Forwarded-* headers by default (0.0.0.0/0, ::/0). This preserves backwards compatibility but is a potential security risk; configure --trusted-proxy-ip to match your reverse proxy addresses.") + trustedProxyIPs = defaultTrustedProxyIPs + } + + return ip.ParseNetSet(trustedProxyIPs) +} + func buildSessionChain(opts *options.Options, provider providers.Provider, sessionStore sessionsapi.SessionStore, validator basic.Validator) alice.Chain { chain := alice.New() @@ -634,6 +647,10 @@ func (p *OAuthProxy) isTrustedIP(req *http.Request) bool { // SignInPage writes the sign in template to the response func (p *OAuthProxy) SignInPage(rw http.ResponseWriter, req *http.Request, code int) { prepareNoCache(rw) + + if err := p.ClearSessionCookie(rw, req); err != nil { + logger.Printf("Error clearing session cookie: %v", err) + } rw.WriteHeader(code) redirectURL, err := p.appDirector.GetRedirect(req) @@ -647,10 +664,6 @@ func (p *OAuthProxy) SignInPage(rw http.ResponseWriter, req *http.Request, code redirectURL = "/" } - if err := p.ClearSessionCookie(rw, req); err != nil { - logger.Printf("Error clearing session cookie: %v", err) - } - p.pageWriter.WriteSignInPage(rw, req, redirectURL, code) } diff --git a/oauthproxy_test.go b/oauthproxy_test.go index e06f50e9..e1235a4e 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -713,6 +713,50 @@ func TestManualSignInCorrectCredentials(t *testing.T) { assert.Equal(t, http.StatusFound, statusCode) } +func TestSignInPageClearsExistingSessionCookie(t *testing.T) { + opts := baseTestOptions() + err := validation.Validate(opts) + require.NoError(t, err) + + proxy, err := NewOAuthProxy(opts, func(string) bool { + return true + }) + require.NoError(t, err) + + // Create a real session cookie using the actual session store. + saveRW := httptest.NewRecorder() + saveReq := httptest.NewRequest(http.MethodGet, "/", nil) + err = proxy.sessionStore.Save(saveRW, saveReq, &sessions.SessionState{ + Email: "john.doe@example.com", + }) + require.NoError(t, err) + + cookies := saveRW.Result().Cookies() + require.NotEmpty(t, cookies) + + // Send that cookie to the sign-in page. + req := httptest.NewRequest(http.MethodGet, "/oauth2/sign_in", nil) + for _, c := range cookies { + req.AddCookie(c) + } + + rw := httptest.NewRecorder() + proxy.ServeHTTP(rw, req) + + assert.Equal(t, http.StatusOK, rw.Code) + + cleared := false + for _, c := range rw.Result().Cookies() { + if c.Name == proxy.CookieOptions.Name { + cleared = true + assert.Equal(t, "", c.Value) + assert.Less(t, c.MaxAge, 0) + } + } + + assert.True(t, cleared, "expected sign-in page to clear existing session cookie") +} + func TestSignInPageIncludesTargetRedirect(t *testing.T) { sipTest, err := NewSignInPageTest(false) if err != nil { @@ -2635,9 +2679,11 @@ func TestAllowedRequest(t *testing.T) { } opts.SkipAuthRegex = []string{ "^/skip/auth/regex$", + "^/public/.*/endpoint$", } opts.SkipAuthRoutes = []string{ "GET=^/skip/auth/routes/get", + "^/foo/.*/bar$", } err := validation.Validate(opts) assert.NoError(t, err) @@ -2670,6 +2716,18 @@ func TestAllowedRequest(t *testing.T) { url: "/wrong/denied", allowed: false, }, + { + name: "Regex allowed with fragment-free path", + method: "GET", + url: "/public/legit/endpoint", + allowed: true, + }, + { + name: "Regex denied when path contains encoded fragment suffix", + method: "GET", + url: "/public/secret%23/endpoint", + allowed: false, + }, { name: "Route allowed", method: "GET", @@ -2694,6 +2752,18 @@ func TestAllowedRequest(t *testing.T) { url: "/skip/auth/routes/wrong/path", allowed: false, }, + { + name: "Route allowed with fragment-free path", + method: "GET", + url: "/foo/public/bar", + allowed: true, + }, + { + name: "Route denied when path contains encoded fragment suffix", + method: "GET", + url: "/foo/secret%23/bar", + allowed: false, + }, } for _, tc := range testCases { @@ -2734,9 +2804,11 @@ func TestAllowedRequestWithForwardedUriHeader(t *testing.T) { } opts.SkipAuthRegex = []string{ "^/skip/auth/regex$", + "^/public/.*/endpoint$", } opts.SkipAuthRoutes = []string{ "GET=^/skip/auth/routes/get", + "^/foo/.*/bar$", } err := validation.Validate(opts) assert.NoError(t, err) @@ -2769,6 +2841,18 @@ func TestAllowedRequestWithForwardedUriHeader(t *testing.T) { url: "/wrong/denied", allowed: false, }, + { + name: "Regex allowed with fragment-free path", + method: "GET", + url: "/public/legit/endpoint", + allowed: true, + }, + { + name: "Regex denied when X-Forwarded-Uri contains an encoded fragment suffix", + method: "GET", + url: "/public/secret%23/endpoint", + allowed: false, + }, { name: "Route allowed", method: "GET", @@ -2793,12 +2877,25 @@ func TestAllowedRequestWithForwardedUriHeader(t *testing.T) { url: "/skip/auth/routes/wrong/path", allowed: false, }, + { + name: "Route allowed with fragment-free path", + method: "GET", + url: "/foo/public/bar", + allowed: true, + }, + { + name: "Route denied when X-Forwarded-Uri contains an encoded fragment suffix", + method: "GET", + url: "/foo/secret%23/bar", + allowed: false, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { req, err := http.NewRequest(tc.method, opts.ProxyPrefix+authOnlyPath, nil) req.Header.Set("X-Forwarded-Uri", tc.url) + req.RemoteAddr = "127.0.0.1:4180" assert.NoError(t, err) rw := httptest.NewRecorder() @@ -2813,6 +2910,43 @@ func TestAllowedRequestWithForwardedUriHeader(t *testing.T) { } } +func TestAllowedRequestWithForwardedUriHeaderRequiresTrustedProxy(t *testing.T) { + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + t.Cleanup(upstreamServer.Close) + + opts := baseTestOptions() + opts.ReverseProxy = true + opts.TrustedProxyIPs = []string{"127.0.0.1/32"} + opts.UpstreamServers = options.UpstreamConfig{ + Upstreams: []options.Upstream{ + { + ID: upstreamServer.URL, + Path: "/", + URI: upstreamServer.URL, + }, + }, + } + opts.SkipAuthRegex = []string{"^/skip/auth/regex$"} + + err := validation.Validate(opts) + assert.NoError(t, err) + + proxy, err := NewOAuthProxy(opts, func(_ string) bool { return true }) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, opts.ProxyPrefix+authOnlyPath, nil) + assert.NoError(t, err) + req.RemoteAddr = "192.0.2.10:4180" + req.Header.Set("X-Forwarded-Uri", "/skip/auth/regex") + + rw := httptest.NewRecorder() + proxy.ServeHTTP(rw, req) + + assert.Equal(t, 401, rw.Code) +} + func TestAllowedRequestNegateWithoutMethod(t *testing.T) { upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) @@ -3404,6 +3538,24 @@ func TestAuthOnlyAllowedEmailDomains(t *testing.T) { querystring: "?allowed_email_domains=a.b.c.example.com,*.c.example.com", expectedStatusCode: http.StatusAccepted, }, + { + name: "UserWithMultipleAtSignsExactDomain", + email: "attacker@evil.com@example.com", + querystring: "?allowed_email_domains=example.com", + expectedStatusCode: http.StatusForbidden, + }, + { + name: "UserWithMultipleAtSignsWildcardDomain", + email: "attacker@evil.com@foo.example.com", + querystring: "?allowed_email_domains=*.example.com", + expectedStatusCode: http.StatusForbidden, + }, + { + name: "UserWithMultipleAtSignsDotPrefixedDomain", + email: "attacker@evil.com@foo.example.com", + querystring: "?allowed_email_domains=.example.com", + expectedStatusCode: http.StatusForbidden, + }, } for _, tc := range testCases { diff --git a/pkg/apis/middleware/scope.go b/pkg/apis/middleware/scope.go index 2d84f00e..b778bd54 100644 --- a/pkg/apis/middleware/scope.go +++ b/pkg/apis/middleware/scope.go @@ -2,9 +2,12 @@ package middleware import ( "context" + "net" "net/http" + "strings" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" ) type scopeKey string @@ -18,9 +21,13 @@ const RequestScopeKey scopeKey = "request-scope" // within the chain. type RequestScope struct { // ReverseProxy tracks whether OAuth2-Proxy is operating in reverse proxy - // mode and if request `X-Forwarded-*` headers should be trusted + // mode and if request `X-Forwarded-*` headers may be trusted ReverseProxy bool + // TrustedProxies tracks which direct callers are allowed to supply + // forwarded headers when ReverseProxy mode is enabled. + TrustedProxies *ip.NetSet + // RequestID is set to the request's `X-Request-Id` header if set. // Otherwise a random UUID is set. RequestID string @@ -58,3 +65,43 @@ func AddRequestScope(req *http.Request, scope *RequestScope) *http.Request { ctx := context.WithValue(req.Context(), RequestScopeKey, scope) return req.WithContext(ctx) } + +// CanTrustForwardedHeaders returns whether forwarded headers should be +// processed for this request. +func (s *RequestScope) CanTrustForwardedHeaders(req *http.Request) bool { + if s == nil || req == nil || !s.ReverseProxy || s.TrustedProxies == nil { + return false + } + + if isUnixSocketRemoteAddr(req.RemoteAddr) { + return true + } + + remoteIP := parseRemoteAddrIP(req.RemoteAddr) + if remoteIP == nil { + return false + } + + return s.TrustedProxies.Has(remoteIP) +} + +func parseRemoteAddrIP(remoteAddr string) net.IP { + if remoteAddr == "" { + return nil + } + + if ip := net.ParseIP(remoteAddr); ip != nil { + return ip + } + + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return nil + } + + return net.ParseIP(host) +} + +func isUnixSocketRemoteAddr(remoteAddr string) bool { + return remoteAddr == "@" || strings.HasPrefix(remoteAddr, "/") +} diff --git a/pkg/apis/middleware/scope_test.go b/pkg/apis/middleware/scope_test.go index f1845518..ec485b47 100644 --- a/pkg/apis/middleware/scope_test.go +++ b/pkg/apis/middleware/scope_test.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -53,4 +54,37 @@ var _ = Describe("Scope Suite", func() { }) }) }) + + Context("CanTrustForwardedHeaders", func() { + var request *http.Request + var scope *middleware.RequestScope + + BeforeEach(func() { + var err error + request, err = http.NewRequest("", "http://127.0.0.1/", nil) + Expect(err).ToNot(HaveOccurred()) + + trustedProxies, err := ip.ParseNetSet([]string{"127.0.0.1"}) + Expect(err).ToNot(HaveOccurred()) + scope = &middleware.RequestScope{ + ReverseProxy: true, + TrustedProxies: trustedProxies, + } + }) + + It("returns true for a trusted remote address", func() { + request.RemoteAddr = "127.0.0.1:4180" + Expect(scope.CanTrustForwardedHeaders(request)).To(BeTrue()) + }) + + It("returns false for an untrusted remote address", func() { + request.RemoteAddr = "192.0.2.10:4180" + Expect(scope.CanTrustForwardedHeaders(request)).To(BeFalse()) + }) + + It("returns true for unix socket callers", func() { + request.RemoteAddr = "@" + Expect(scope.CanTrustForwardedHeaders(request)).To(BeTrue()) + }) + }) }) diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go index 42083f76..40f9a725 100644 --- a/pkg/apis/options/load_test.go +++ b/pkg/apis/options/load_test.go @@ -329,7 +329,7 @@ var _ = Describe("Load", func() { Entry("with an unknown option in the config file", &testOptionsTableInput{ configFile: []byte(`unknown_option="foo"`), flagSet: func() *pflag.FlagSet { return testOptionsFlagSet }, - expectedErr: fmt.Errorf("error unmarshalling config: decoding failed due to the following error(s):\n\n'' has invalid keys: unknown_option"), + expectedErr: fmt.Errorf("error unmarshalling config: decoding failed due to the following error(s):\n\n'options.TestOptions' has invalid keys: unknown_option"), // Viper will unmarshal before returning the error, so this is the default output expectedOutput: &TestOptions{ StringOption: "default", diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go index 2ca3a766..2756014b 100644 --- a/pkg/apis/options/options.go +++ b/pkg/apis/options/options.go @@ -24,6 +24,7 @@ type Options struct { ReadyPath string `flag:"ready-path" cfg:"ready_path"` ReverseProxy bool `flag:"reverse-proxy" cfg:"reverse_proxy"` RealClientIPHeader string `flag:"real-client-ip-header" cfg:"real_client_ip_header"` + TrustedProxyIPs []string `flag:"trusted-proxy-ip" cfg:"trusted_proxy_ips"` TrustedIPs []string `flag:"trusted-ip" cfg:"trusted_ips"` ForceHTTPS bool `flag:"force-https" cfg:"force_https"` RawRedirectURL string `flag:"redirect-url" cfg:"redirect_url"` @@ -119,6 +120,7 @@ func NewFlagSet() *pflag.FlagSet { flagSet.Bool("reverse-proxy", false, "are we running behind a reverse proxy, controls whether headers like X-Real-Ip are accepted") flagSet.String("real-client-ip-header", "X-Real-IP", "Header used to determine the real IP of the client (one of: X-Forwarded-For, X-Real-IP, X-ProxyUser-IP, X-Envoy-External-Address, or CF-Connecting-IP)") + flagSet.StringSlice("trusted-proxy-ip", []string{}, "list of IPs or CIDR ranges that are allowed to set X-Forwarded-* headers when --reverse-proxy is enabled. Defaults to trusting all IPs for backwards compatibility; configure this to your reverse proxy addresses to prevent header spoofing.") flagSet.StringSlice("trusted-ip", []string{}, "list of IPs or CIDR ranges to allow to bypass authentication. WARNING: trusting by IP has inherent security flaws, read the configuration documentation for more information.") flagSet.Bool("force-https", false, "force HTTPS redirect for HTTP requests") flagSet.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"") diff --git a/pkg/app/redirect/director_test.go b/pkg/app/redirect/director_test.go index 69c01d95..58d0e8c3 100644 --- a/pkg/app/redirect/director_test.go +++ b/pkg/app/redirect/director_test.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -33,9 +34,16 @@ var _ = Describe("Director Suite", func() { req.Header.Add(header, value) } } - req = middleware.AddRequestScope(req, &middleware.RequestScope{ + scope := &middleware.RequestScope{ ReverseProxy: in.reverseProxy, - }) + } + if in.reverseProxy { + req.RemoteAddr = "127.0.0.1:4180" + trustedProxies, err := ip.ParseNetSet([]string{"127.0.0.1"}) + Expect(err).ToNot(HaveOccurred()) + scope.TrustedProxies = trustedProxies + } + req = middleware.AddRequestScope(req, scope) redirect, err := appDirector.GetRedirect(req) Expect(err).ToNot(HaveOccurred()) @@ -174,4 +182,27 @@ var _ = Describe("Director Suite", func() { expectedRedirect: "https://a-service.example.com/foo/bar", }), ) + + It("ignores forwarded headers from an untrusted remote address", func() { + appDirector := NewAppDirector(AppDirectorOpts{ + ProxyPrefix: testProxyPrefix, + Validator: testValidator(true), + }) + + req, _ := http.NewRequest("GET", "https://oauth.example.com/foo?bar", nil) + req.RemoteAddr = "192.0.2.10:4180" + req.Header.Add("X-Forwarded-Proto", "https") + req.Header.Add("X-Forwarded-Host", "a-service.example.com") + req.Header.Add("X-Forwarded-Uri", fooBar) + trustedProxies, err := ip.ParseNetSet([]string{"127.0.0.1"}) + Expect(err).ToNot(HaveOccurred()) + req = middleware.AddRequestScope(req, &middleware.RequestScope{ + ReverseProxy: true, + TrustedProxies: trustedProxies, + }) + + redirect, err := appDirector.GetRedirect(req) + Expect(err).ToNot(HaveOccurred()) + Expect(redirect).To(Equal("/foo?bar")) + }) }) diff --git a/pkg/cookies/cookies_test.go b/pkg/cookies/cookies_test.go index b67f8a69..ba8b5480 100644 --- a/pkg/cookies/cookies_test.go +++ b/pkg/cookies/cookies_test.go @@ -6,6 +6,7 @@ import ( "time" middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -30,8 +31,13 @@ var _ = Describe("Cookie Tests", func() { if in.xForwardedHost != "" { req.Header.Add("X-Forwarded-Host", in.xForwardedHost) + req.RemoteAddr = "127.0.0.1:4180" + trustedProxies, err := ip.ParseNetSet([]string{"127.0.0.1"}) + Expect(err).ToNot(HaveOccurred()) + req = middlewareapi.AddRequestScope(req, &middlewareapi.RequestScope{ - ReverseProxy: true, + ReverseProxy: true, + TrustedProxies: trustedProxies, }) } diff --git a/pkg/ip/parse_ip_net.go b/pkg/ip/parse_ip_net.go index 9cb37de2..4fc63664 100644 --- a/pkg/ip/parse_ip_net.go +++ b/pkg/ip/parse_ip_net.go @@ -1,6 +1,7 @@ package ip import ( + "fmt" "net" "strings" ) @@ -37,3 +38,18 @@ func ParseIPNet(s string) *net.IPNet { return ipNet } } + +func ParseNetSet(ipStrs []string) (*NetSet, error) { + netSet := NewNetSet() + + for _, ipStr := range ipStrs { + ipNet := ParseIPNet(ipStr) + if ipNet == nil { + return nil, fmt.Errorf("could not parse IP network (%s)", ipStr) + } + + netSet.AddIPNet(*ipNet) + } + + return netSet, nil +} diff --git a/pkg/ip/parse_ip_net_test.go b/pkg/ip/parse_ip_net_test.go new file mode 100644 index 00000000..41c1b4b9 --- /dev/null +++ b/pkg/ip/parse_ip_net_test.go @@ -0,0 +1,118 @@ +package ip + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseIPNet(t *testing.T) { + tests := []struct { + name string + input string + expectedIP net.IP + expectedMask net.IPMask + }{ + { + name: "ipv4 address", + input: "127.0.0.1", + expectedIP: net.ParseIP("127.0.0.1"), + expectedMask: net.CIDRMask(32, 32), + }, + { + name: "ipv6 address", + input: "::1", + expectedIP: net.ParseIP("::1"), + expectedMask: net.CIDRMask(128, 128), + }, + { + name: "ipv4 cidr", + input: "10.0.0.0/24", + expectedIP: net.ParseIP("10.0.0.0"), + expectedMask: net.CIDRMask(24, 32), + }, + { + name: "ipv6 cidr", + input: "2001:db8::/64", + expectedIP: net.ParseIP("2001:db8::"), + expectedMask: net.CIDRMask(64, 128), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ipNet := ParseIPNet(test.input) + + assert.NotNil(t, ipNet) + if ipNet == nil { + return + } + + assert.True(t, test.expectedIP.Equal(ipNet.IP)) + assert.Equal(t, test.expectedMask, ipNet.Mask) + }) + } +} + +func TestParseIPNetRejectsInvalidNetworks(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "invalid ip", + input: "not-an-ip", + }, + { + name: "ipv4 cidr with host bits set", + input: "10.0.0.1/24", + }, + { + name: "ipv6 cidr with host bits set", + input: "2001:db8::1/64", + }, + { + name: "invalid cidr mask", + input: "10.0.0.0/33", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Nil(t, ParseIPNet(test.input)) + }) + } +} + +func TestParseNetSet(t *testing.T) { + netSet, err := ParseNetSet([]string{ + "127.0.0.1", + "10.0.0.0/24", + "::1", + "2001:db8::/64", + }) + + assert.NoError(t, err) + assert.NotNil(t, netSet) + if netSet == nil { + return + } + + assert.True(t, netSet.Has(net.ParseIP("127.0.0.1"))) + assert.True(t, netSet.Has(net.ParseIP("10.0.0.55"))) + assert.True(t, netSet.Has(net.ParseIP("::1"))) + assert.True(t, netSet.Has(net.ParseIP("2001:db8::abcd"))) + + assert.False(t, netSet.Has(net.ParseIP("127.0.0.2"))) + assert.False(t, netSet.Has(net.ParseIP("10.0.1.1"))) + assert.False(t, netSet.Has(net.ParseIP("::2"))) + assert.False(t, netSet.Has(net.ParseIP("2001:db9::1"))) +} + +func TestParseNetSetReturnsErrorForInvalidNetwork(t *testing.T) { + netSet, err := ParseNetSet([]string{"127.0.0.1", "10.0.0.1/24"}) + + assert.Nil(t, netSet) + assert.EqualError(t, err, "could not parse IP network (10.0.0.1/24)") +} diff --git a/pkg/middleware/healthcheck.go b/pkg/middleware/healthcheck.go index 2dcfc1d4..de3b63d2 100644 --- a/pkg/middleware/healthcheck.go +++ b/pkg/middleware/healthcheck.go @@ -43,10 +43,13 @@ func healthCheck(paths, userAgents []string, next http.Handler) http.Handler { func isHealthCheckRequest(paths, userAgents map[string]struct{}, req *http.Request) bool { if _, ok := paths[req.URL.EscapedPath()]; ok { - return true - } - if _, ok := userAgents[req.Header.Get("User-Agent")]; ok { - return true + if len(userAgents) == 0 { + return true + } + + if _, ok := userAgents[req.Header.Get("User-Agent")]; ok { + return true + } } return false } diff --git a/pkg/middleware/healthcheck_test.go b/pkg/middleware/healthcheck_test.go index 78e1e6d4..68a8d3ec 100644 --- a/pkg/middleware/healthcheck_test.go +++ b/pkg/middleware/healthcheck_test.go @@ -45,6 +45,16 @@ var _ = Describe("HealthCheck suite", func() { healthCheckPaths: []string{"/ping"}, healthCheckUserAgents: []string{"hc/1.0"}, requestString: "http://example.com/ping", + headers: map[string]string{ + "User-Agent": "hc/1.0", + }, + expectedStatus: 200, + expectedBody: "OK", + }), + Entry("when requesting the healthcheck path with no health check user agents configured", &requestTableInput{ + healthCheckPaths: []string{"/ping"}, + healthCheckUserAgents: []string{}, + requestString: "http://example.com/ping", headers: map[string]string{}, expectedStatus: 200, expectedBody: "OK", @@ -85,15 +95,25 @@ var _ = Describe("HealthCheck suite", func() { expectedStatus: 404, expectedBody: "404 page not found\n", }), - Entry("with a request from the health check user agent", &requestTableInput{ + Entry("with a request from the health check user agent on a non-healthcheck path", &requestTableInput{ healthCheckPaths: []string{"/ping"}, healthCheckUserAgents: []string{"hc/1.0"}, requestString: "http://example.com/abc", headers: map[string]string{ "User-Agent": "hc/1.0", }, - expectedStatus: 200, - expectedBody: "OK", + expectedStatus: 404, + expectedBody: "404 page not found\n", + }), + Entry("when an auth_request endpoint receives the configured health check user agent", &requestTableInput{ + healthCheckPaths: []string{"/ping"}, + healthCheckUserAgents: []string{"GoogleHC/1.0"}, + requestString: "http://example.com/oauth2/auth", + headers: map[string]string{ + "User-Agent": "GoogleHC/1.0", + }, + expectedStatus: 404, + expectedBody: "404 page not found\n", }), Entry("when a blank string is configured as a health check agent and a request has no user agent", &requestTableInput{ healthCheckPaths: []string{"/ping"}, @@ -107,9 +127,11 @@ var _ = Describe("HealthCheck suite", func() { healthCheckPaths: []string{"/ping", "/liveness_check", "/readiness_check"}, healthCheckUserAgents: []string{"hc/1.0"}, requestString: "http://example.com/readiness_check", - headers: map[string]string{}, - expectedStatus: 200, - expectedBody: "OK", + headers: map[string]string{ + "User-Agent": "hc/1.0", + }, + expectedStatus: 200, + expectedBody: "OK", }), Entry("with multiple paths, request none of the healthcheck paths", &requestTableInput{ healthCheckPaths: []string{"/ping", "/liveness_check", "/readiness_check"}, @@ -121,15 +143,15 @@ var _ = Describe("HealthCheck suite", func() { expectedStatus: 404, expectedBody: "404 page not found\n", }), - Entry("with multiple user agents, request from a health check user agent", &requestTableInput{ + Entry("with multiple user agents, request from a health check user agent on a non-healthcheck path", &requestTableInput{ healthCheckPaths: []string{"/ping"}, healthCheckUserAgents: []string{"hc/1.0", "GoogleHC/1.0"}, requestString: "http://example.com/abc", headers: map[string]string{ "User-Agent": "GoogleHC/1.0", }, - expectedStatus: 200, - expectedBody: "OK", + expectedStatus: 404, + expectedBody: "404 page not found\n", }), Entry("with multiple user agents, request from none of the health check user agents", &requestTableInput{ healthCheckPaths: []string{"/ping"}, diff --git a/pkg/middleware/redirect_to_https_test.go b/pkg/middleware/redirect_to_https_test.go index 6e8a4368..9c7fb751 100644 --- a/pkg/middleware/redirect_to_https_test.go +++ b/pkg/middleware/redirect_to_https_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -39,6 +40,10 @@ var _ = Describe("RedirectToHTTPS suite", func() { scope := &middlewareapi.RequestScope{ ReverseProxy: in.reverseProxy, } + if in.reverseProxy { + req.RemoteAddr = "127.0.0.1:4180" + scope.TrustedProxies = newRedirectTrustedProxySet("127.0.0.1") + } req = middlewareapi.AddRequestScope(req, scope) rw := httptest.NewRecorder() @@ -207,3 +212,13 @@ var _ = Describe("RedirectToHTTPS suite", func() { }), ) }) + +func newRedirectTrustedProxySet(cidrs ...string) *ip.NetSet { + set := ip.NewNetSet() + for _, cidr := range cidrs { + ipNet := ip.ParseIPNet(cidr) + Expect(ipNet).ToNot(BeNil()) + set.AddIPNet(*ipNet) + } + return set +} diff --git a/pkg/middleware/scope.go b/pkg/middleware/scope.go index d0dd81ec..84b1573c 100644 --- a/pkg/middleware/scope.go +++ b/pkg/middleware/scope.go @@ -6,14 +6,16 @@ import ( "github.com/google/uuid" "github.com/justinas/alice" middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" ) -func NewScope(reverseProxy bool, idHeader string) alice.Constructor { +func NewScope(reverseProxy bool, idHeader string, trustedProxies *ip.NetSet) alice.Constructor { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { scope := &middlewareapi.RequestScope{ - ReverseProxy: reverseProxy, - RequestID: genRequestID(req, idHeader), + ReverseProxy: reverseProxy, + TrustedProxies: trustedProxies, + RequestID: genRequestID(req, idHeader), } req = middlewareapi.AddRequestScope(req, scope) next.ServeHTTP(rw, req) diff --git a/pkg/middleware/scope_test.go b/pkg/middleware/scope_test.go index fa680667..db2c3016 100644 --- a/pkg/middleware/scope_test.go +++ b/pkg/middleware/scope_test.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -32,7 +33,7 @@ var _ = Describe("Scope Suite", func() { Context("ReverseProxy is false", func() { BeforeEach(func() { - handler := NewScope(false, testRequestHeader)( + handler := NewScope(false, testRequestHeader, nil)( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextRequest = r w.WriteHeader(200) @@ -60,8 +61,15 @@ var _ = Describe("Scope Suite", func() { }) Context("ReverseProxy is true", func() { + var trustedProxies *ip.NetSet + BeforeEach(func() { - handler := NewScope(true, testRequestHeader)( + var err error + + trustedProxies, err = ip.ParseNetSet([]string{"127.0.0.1"}) + Expect(err).ToNot(HaveOccurred()) + + handler := NewScope(true, testRequestHeader, trustedProxies)( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextRequest = r w.WriteHeader(200) @@ -74,12 +82,18 @@ var _ = Describe("Scope Suite", func() { Expect(scope).ToNot(BeNil()) Expect(scope.ReverseProxy).To(BeTrue()) }) + + It("stores the trusted proxies on the scope", func() { + scope := middlewareapi.GetRequestScope(nextRequest) + Expect(scope).ToNot(BeNil()) + Expect(scope.TrustedProxies).To(BeIdenticalTo(trustedProxies)) + }) }) Context("Request ID header is present", func() { BeforeEach(func() { request.Header.Add(testRequestHeader, testRequestID) - handler := NewScope(false, testRequestHeader)( + handler := NewScope(false, testRequestHeader, nil)( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextRequest = r w.WriteHeader(200) @@ -97,7 +111,7 @@ var _ = Describe("Scope Suite", func() { BeforeEach(func() { uuid.SetRand(mockRand{}) - handler := NewScope(true, testRequestHeader)( + handler := NewScope(true, testRequestHeader, nil)( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextRequest = r w.WriteHeader(200) diff --git a/pkg/middleware/stored_session.go b/pkg/middleware/stored_session.go index 72c364e7..53238f19 100644 --- a/pkg/middleware/stored_session.go +++ b/pkg/middleware/stored_session.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/justinas/alice" @@ -31,6 +32,33 @@ const ( sessionRefreshRetryPeriod = 10 * time.Millisecond ) +// isFatalRefreshError checks if a refresh error indicates a revoked or +// non-existent session that should be immediately invalidated. +// Fatal errors indicate the session is no longer valid at the provider level. +// Non-fatal errors (network issues, timeouts) should not invalidate the session. +// +// Only checks standard OAuth2 error codes (RFC 6749 Section 5.2). +// Does NOT check error_description strings as they are optional and provider-specific. +func isFatalRefreshError(err error) bool { + if err == nil { + return false + } + + // Only check standard OAuth2 error codes (RFC 6749 Section 5.2) + // Do NOT check error_description strings as they are optional and provider-specific + fatalErrors := []string{ + "invalid_grant", // refresh token revoked, expired, or session terminated + "invalid_client", // client credentials no longer valid + } + + for _, fe := range fatalErrors { + if strings.Contains(err.Error(), fe) { + return true + } + } + return false +} + // StoredSessionLoaderOptions contains all of the requirements to construct // a stored session loader. // All options must be provided. @@ -188,9 +216,24 @@ func (s *storedSessionLoader) refreshSessionIfNeeded(rw http.ResponseWriter, req // We are holding the lock and the session needs a refresh logger.Printf("Refreshing session - User: %s; SessionAge: %s", session.User, session.Age()) if err := s.refreshSession(rw, req, session); err != nil { - // If a preemptive refresh fails, we still keep the session - // if validateSession succeeds. logger.Errorf("Unable to refresh session: %v", err) + + // Check if this is a fatal error that indicates the session is revoked + // or no longer valid at the provider level + if isFatalRefreshError(err) { + logger.Printf("Fatal refresh error detected (session revoked or invalid), clearing session for user: %s", session.User) + + // Clear the session from storage (Redis) and remove the cookie + if err := s.store.Clear(rw, req); err != nil { + logger.Errorf("failed clearing session: %v", err) + } + + // Return error immediately to force re-authentication + return fmt.Errorf("session invalidated due to fatal refresh error: %w", err) + } + + // For non-fatal errors (network issues, timeouts), keep the session + // and let validateSession determine if it's still usable } // Validate all sessions after any Redeem/Refresh operation (fail or success) diff --git a/pkg/middleware/stored_session_test.go b/pkg/middleware/stored_session_test.go index d8e78f2f..c913a4ef 100644 --- a/pkg/middleware/stored_session_test.go +++ b/pkg/middleware/stored_session_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "sync" + "testing" "time" middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" @@ -801,3 +802,53 @@ func (f *fakeSessionStore) Clear(rw http.ResponseWriter, req *http.Request) erro func (f *fakeSessionStore) VerifyConnection(_ context.Context) error { return nil } + +// TestIsFatalRefreshError tests the isFatalRefreshError function to ensure +// it correctly identifies fatal OAuth2 errors that should invalidate a session. +func TestIsFatalRefreshError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "invalid_grant error", + err: fmt.Errorf("failed to get token: oauth2: \"invalid_grant\" \"Session not active\""), + expected: true, + }, + { + name: "invalid_client error", + err: fmt.Errorf("invalid_client: client not found"), + expected: true, + }, + { + name: "network timeout - not fatal", + err: fmt.Errorf("Post \"https://keycloak/token\": dial tcp: connect: connection refused"), + expected: false, + }, + { + name: "server error - not fatal", + err: fmt.Errorf("unexpected status code 500"), + expected: false, + }, + { + name: "generic refresh error - not fatal", + err: fmt.Errorf("error refreshing tokens: context deadline exceeded"), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isFatalRefreshError(tt.err) + if result != tt.expected { + t.Errorf("isFatalRefreshError(%v) = %v, want %v", tt.err, result, tt.expected) + } + }) + } +} diff --git a/pkg/requests/util/util.go b/pkg/requests/util/util.go index 290f8059..568ebcc6 100644 --- a/pkg/requests/util/util.go +++ b/pkg/requests/util/util.go @@ -15,30 +15,30 @@ const ( ) // GetRequestProto returns the request scheme or X-Forwarded-Proto if present -// and the request is proxied. +// and the request came from a trusted reverse proxy. func GetRequestProto(req *http.Request) string { proto := req.Header.Get(XForwardedProto) - if !IsProxied(req) || proto == "" { + if !CanTrustForwardedHeaders(req) || proto == "" { proto = req.URL.Scheme } return proto } // GetRequestHost returns the request host header or X-Forwarded-Host if -// present and the request is proxied. +// present and the request came from a trusted reverse proxy. func GetRequestHost(req *http.Request) string { host := req.Header.Get(XForwardedHost) - if !IsProxied(req) || host == "" { + if !CanTrustForwardedHeaders(req) || host == "" { host = req.Host } return host } // GetRequestURI return the request URI or X-Forwarded-Uri if present and the -// request is proxied. +// request came from a trusted reverse proxy. func GetRequestURI(req *http.Request) string { uri := req.Header.Get(XForwardedURI) - if !IsProxied(req) || uri == "" { + if !CanTrustForwardedHeaders(req) || uri == "" { // Use RequestURI to preserve ?query uri = req.URL.RequestURI() } @@ -46,17 +46,29 @@ func GetRequestURI(req *http.Request) string { } // GetRequestPath returns the request URI or X-Forwarded-Uri if present and the -// request is proxied but always strips the query parameters and only returns -// the pure path +// request came from a trusted reverse proxy but always strips the query +// parameters and fragment suffixes and only returns the pure path. func GetRequestPath(req *http.Request) string { - uri := GetRequestURI(req) + uri := stripRequestFragment(GetRequestURI(req)) // Parse URI and return only the path component if parsedURL, err := url.Parse(uri); err == nil { - return parsedURL.Path + return stripRequestFragment(parsedURL.Path) } // Fallback: strip query parameters manually + return stripRequestQuery(uri) +} + +func stripRequestFragment(uri string) string { + if idx := strings.Index(uri, "#"); idx != -1 { + return uri[:idx] + } + + return uri +} + +func stripRequestQuery(uri string) string { if idx := strings.Index(uri, "?"); idx != -1 { return uri[:idx] } @@ -64,17 +76,18 @@ func GetRequestPath(req *http.Request) string { return uri } -// IsProxied determines if a request was from a proxy based on the RequestScope -// ReverseProxy tracker. -func IsProxied(req *http.Request) bool { +// CanTrustForwardedHeaders determines if forwarded headers should be processed +// based on the RequestScope and the direct caller's address. +func CanTrustForwardedHeaders(req *http.Request) bool { scope := middlewareapi.GetRequestScope(req) if scope == nil { return false } - return scope.ReverseProxy + + return scope.CanTrustForwardedHeaders(req) } func IsForwardedRequest(req *http.Request) bool { - return IsProxied(req) && + return CanTrustForwardedHeaders(req) && req.Host != GetRequestHost(req) } diff --git a/pkg/requests/util/util_test.go b/pkg/requests/util/util_test.go index ba72c66d..c4185b35 100644 --- a/pkg/requests/util/util_test.go +++ b/pkg/requests/util/util_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests/util" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -19,8 +20,13 @@ var _ = Describe("Util Suite", func() { uriNoQueryParams = "/test/endpoint" ) var req *http.Request + var trustedProxies *ip.NetSet BeforeEach(func() { + var err error + trustedProxies, err = ip.ParseNetSet([]string{"127.0.0.1"}) + Expect(err).ToNot(HaveOccurred()) + req = httptest.NewRequest( http.MethodGet, fmt.Sprintf("%s://%s%s", proto, host, uriWithQueryParams), @@ -29,7 +35,7 @@ var _ = Describe("Util Suite", func() { }) Context("GetRequestHost", func() { - Context("IsProxied is false", func() { + Context("trusted forwarded headers are disabled", func() { BeforeEach(func() { req = middleware.AddRequestScope(req, &middleware.RequestScope{}) }) @@ -44,10 +50,12 @@ var _ = Describe("Util Suite", func() { }) }) - Context("IsProxied is true", func() { + Context("trusted forwarded headers are enabled", func() { BeforeEach(func() { + req.RemoteAddr = "127.0.0.1:4180" req = middleware.AddRequestScope(req, &middleware.RequestScope{ - ReverseProxy: true, + ReverseProxy: true, + TrustedProxies: trustedProxies, }) }) @@ -63,7 +71,7 @@ var _ = Describe("Util Suite", func() { }) Context("GetRequestProto", func() { - Context("IsProxied is false", func() { + Context("trusted forwarded headers are disabled", func() { BeforeEach(func() { req = middleware.AddRequestScope(req, &middleware.RequestScope{}) }) @@ -78,10 +86,12 @@ var _ = Describe("Util Suite", func() { }) }) - Context("IsProxied is true", func() { + Context("trusted forwarded headers are enabled", func() { BeforeEach(func() { + req.RemoteAddr = "127.0.0.1:4180" req = middleware.AddRequestScope(req, &middleware.RequestScope{ - ReverseProxy: true, + ReverseProxy: true, + TrustedProxies: trustedProxies, }) }) @@ -97,7 +107,7 @@ var _ = Describe("Util Suite", func() { }) Context("GetRequestURI", func() { - Context("IsProxied is false", func() { + Context("trusted forwarded headers are disabled", func() { BeforeEach(func() { req = middleware.AddRequestScope(req, &middleware.RequestScope{}) }) @@ -112,10 +122,12 @@ var _ = Describe("Util Suite", func() { }) }) - Context("IsProxied is true", func() { + Context("trusted forwarded headers are enabled", func() { BeforeEach(func() { + req.RemoteAddr = "127.0.0.1:4180" req = middleware.AddRequestScope(req, &middleware.RequestScope{ - ReverseProxy: true, + ReverseProxy: true, + TrustedProxies: trustedProxies, }) }) @@ -131,7 +143,7 @@ var _ = Describe("Util Suite", func() { }) Context("GetRequestPath", func() { - Context("IsProxied is false", func() { + Context("trusted forwarded headers are disabled", func() { BeforeEach(func() { req = middleware.AddRequestScope(req, &middleware.RequestScope{}) }) @@ -140,16 +152,35 @@ var _ = Describe("Util Suite", func() { Expect(util.GetRequestPath(req)).To(Equal(uriNoQueryParams)) }) + It("drops fragment content from a parsed request path", func() { + // Simulate net/http ParseRequestURI preserving '#' in URL.Path. + req.URL.Path = "/foo/secret#/bar" + req.URL.RawPath = "/foo/secret%23/bar" + Expect(util.GetRequestPath(req)).To(Equal("/foo/secret")) + }) + + It("drops fragment-like suffixes from encoded number signs", func() { + req = httptest.NewRequest( + http.MethodGet, + fmt.Sprintf("%s://%s/foo/secret%%23/bar?query=param", proto, host), + nil, + ) + req = middleware.AddRequestScope(req, &middleware.RequestScope{}) + Expect(util.GetRequestPath(req)).To(Equal("/foo/secret")) + }) + It("ignores X-Forwarded-Uri and returns the URI (without query params)", func() { req.Header.Add("X-Forwarded-Uri", "/some/other/path?query=param") Expect(util.GetRequestPath(req)).To(Equal(uriNoQueryParams)) }) }) - Context("IsProxied is true", func() { + Context("trusted forwarded headers are enabled", func() { BeforeEach(func() { + req.RemoteAddr = "127.0.0.1:4180" req = middleware.AddRequestScope(req, &middleware.RequestScope{ - ReverseProxy: true, + ReverseProxy: true, + TrustedProxies: trustedProxies, }) }) @@ -161,6 +192,37 @@ var _ = Describe("Util Suite", func() { req.Header.Add("X-Forwarded-Uri", "/some/other/path?query=param") Expect(util.GetRequestPath(req)).To(Equal("/some/other/path")) }) + + It("drops fragment-like suffixes from the X-Forwarded-Uri", func() { + req.Header.Add("X-Forwarded-Uri", "/foo/secret%23/bar?query=param") + Expect(util.GetRequestPath(req)).To(Equal("/foo/secret")) + }) + }) + }) + + Context("CanTrustForwardedHeaders", func() { + It("returns false when no scope is present", func() { + Expect(util.CanTrustForwardedHeaders(req)).To(BeFalse()) + }) + + It("returns true when the remote address is trusted", func() { + req.RemoteAddr = "127.0.0.1:4180" + req = middleware.AddRequestScope(req, &middleware.RequestScope{ + ReverseProxy: true, + TrustedProxies: trustedProxies, + }) + + Expect(util.CanTrustForwardedHeaders(req)).To(BeTrue()) + }) + + It("returns false when the remote address is untrusted", func() { + req.RemoteAddr = "192.0.2.10:4180" + req = middleware.AddRequestScope(req, &middleware.RequestScope{ + ReverseProxy: true, + TrustedProxies: trustedProxies, + }) + + Expect(util.CanTrustForwardedHeaders(req)).To(BeFalse()) }) }) }) diff --git a/pkg/upstream/http_test.go b/pkg/upstream/http_test.go index a01d5c09..70af9e7e 100644 --- a/pkg/upstream/http_test.go +++ b/pkg/upstream/http_test.go @@ -498,7 +498,7 @@ var _ = Describe("HTTP Upstream Suite", func() { handler := newHTTPUpstreamProxy(upstream, u, nil, nil) - proxyServer = httptest.NewServer(middleware.NewScope(false, "X-Request-Id")(handler)) + proxyServer = httptest.NewServer(middleware.NewScope(false, "X-Request-Id", nil)(handler)) }) AfterEach(func() { @@ -549,7 +549,7 @@ var _ = Describe("HTTP Upstream Suite", func() { Expect(err).ToNot(HaveOccurred()) handler := newHTTPUpstreamProxy(upstream, u, nil, nil) - noPassHostServer := httptest.NewServer(middleware.NewScope(false, "X-Request-Id")(handler)) + noPassHostServer := httptest.NewServer(middleware.NewScope(false, "X-Request-Id", nil)(handler)) defer noPassHostServer.Close() origin := "http://example.localhost" diff --git a/pkg/validation/allowlist.go b/pkg/validation/allowlist.go index a74f4ae9..dcc0d361 100644 --- a/pkg/validation/allowlist.go +++ b/pkg/validation/allowlist.go @@ -16,6 +16,7 @@ func validateAllowlists(o *options.Options) []string { msgs = append(msgs, validateAuthRoutes(o)...) msgs = append(msgs, validateAuthRegexes(o)...) + msgs = append(msgs, validateTrustedProxyIPs(o)...) msgs = append(msgs, validateTrustedIPs(o)...) if len(o.TrustedIPs) > 0 && o.ReverseProxy { @@ -28,6 +29,17 @@ func validateAllowlists(o *options.Options) []string { return msgs } +// validateTrustedProxyIPs validates IP/CIDRs for trusted reverse proxies. +func validateTrustedProxyIPs(o *options.Options) []string { + msgs := []string{} + for i, ipStr := range o.TrustedProxyIPs { + if ip.ParseIPNet(ipStr) == nil { + msgs = append(msgs, fmt.Sprintf("trusted_proxy_ips[%d] (%s) could not be recognized", i, ipStr)) + } + } + return msgs +} + // validateAuthRoutes validates method=path routes passed with options.SkipAuthRoutes func validateAuthRoutes(o *options.Options) []string { msgs := []string{} diff --git a/pkg/validation/allowlist_test.go b/pkg/validation/allowlist_test.go index 9f6843dd..ae4c29ef 100644 --- a/pkg/validation/allowlist_test.go +++ b/pkg/validation/allowlist_test.go @@ -23,6 +23,11 @@ var _ = Describe("Allowlist", func() { errStrings []string } + type validateTrustedProxyIPsTableInput struct { + trustedProxyIPs []string + errStrings []string + } + DescribeTable("validateRoutes", func(r *validateRoutesTableInput) { opts := &options.Options{ @@ -121,4 +126,29 @@ var _ = Describe("Allowlist", func() { }, }), ) + + DescribeTable("validateTrustedProxyIPs", + func(t *validateTrustedProxyIPsTableInput) { + opts := &options.Options{ + TrustedProxyIPs: t.trustedProxyIPs, + } + Expect(validateTrustedProxyIPs(opts)).To(ConsistOf(t.errStrings)) + }, + Entry("Valid trusted proxy IPs", &validateTrustedProxyIPsTableInput{ + trustedProxyIPs: []string{ + "127.0.0.1", + "10.32.0.1/32", + "::1", + "2a12:105:ee7:9234:0:0:0:0/64", + }, + errStrings: []string{}, + }), + Entry("Invalid trusted proxy IPs", &validateTrustedProxyIPsTableInput{ + trustedProxyIPs: []string{"[::1]", "alkwlkbn/32"}, + errStrings: []string{ + "trusted_proxy_ips[0] ([::1]) could not be recognized", + "trusted_proxy_ips[1] (alkwlkbn/32) could not be recognized", + }, + }), + ) }) diff --git a/validator.go b/validator.go index 587ef857..d03157a5 100644 --- a/validator.go +++ b/validator.go @@ -110,6 +110,10 @@ func NewValidator(domains []string, usersFile string) func(string) bool { // isEmailValidWithDomains checks if the authenticated email is validated against the provided domain func isEmailValidWithDomains(email string, allowedDomains []string) bool { + if strings.Count(email, "@") != 1 { + return false + } + for _, domain := range allowedDomains { // allow if the domain is perfect suffix match with the email if strings.HasSuffix(email, "@"+domain) { @@ -119,7 +123,6 @@ func isEmailValidWithDomains(email string, allowedDomains []string) bool { // allow if the domain is prefixed with . or *. and // the last element (split on @) has the suffix as the domain atoms := strings.Split(email, "@") - if (strings.HasPrefix(domain, ".") && strings.HasSuffix(atoms[len(atoms)-1], domain)) || (strings.HasPrefix(domain, "*.") && strings.HasSuffix(atoms[len(atoms)-1], domain[1:])) { return true diff --git a/validator_test.go b/validator_test.go index 976c0d7d..c406a732 100644 --- a/validator_test.go +++ b/validator_test.go @@ -404,6 +404,27 @@ func TestValidatorCases(t *testing.T) { allowedDomains: []string{"*.company.com"}, expectedAuthZ: false, }, + { + name: "CheckThatTwoAtSignsIsInvalid", + email: "attacker@evil.com@company.com", + allowedEmails: []string(nil), + allowedDomains: []string{"company.com"}, + expectedAuthZ: false, + }, + { + name: "CheckThatTwoAtSignsIsInvalidEvenWithDotPrefix", + email: "attacker@evil.com@company.com", + allowedEmails: []string(nil), + allowedDomains: []string{".company.com"}, + expectedAuthZ: false, + }, + { + name: "CheckThatTwoAtSignsIsInvalidEvenWithWildcardPrefix", + email: "attacker@evil.com@foo.company.com", + allowedEmails: []string(nil), + allowedDomains: []string{"*.company.com"}, + expectedAuthZ: false, + }, } for _, tc := range testCases {