diff --git a/.github/workflows/fhem_test.yml b/.github/workflows/fhem_test.yml index 757df0a..89e0ecb 100644 --- a/.github/workflows/fhem_test.yml +++ b/.github/workflows/fhem_test.yml @@ -79,7 +79,7 @@ jobs: - name: Push generated files uses: ad-m/github-push-action@v1.0.0 with: - github_token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.BOT_PUSH_TOKEN }} branch: ${{ steps.branch.outputs.name }} test: @@ -97,10 +97,6 @@ jobs: install-modules-args: --notest - name: Install CPAN dependencies run: cpanm --installdeps . --notest - - name: Verify generated README content - run: | - perl scripts/update-readme-from-module-docs.pl - git diff --exit-code README.md - name: Install FHEM via debian nightly uses: fhem/setup-fhem@v1.0.1 - name: Change ownership of /opt/fhem diff --git a/CHANGED b/CHANGED index 139b029..3bc61db 100644 --- a/CHANGED +++ b/CHANGED @@ -1,3 +1,17 @@ +2026-04-03 - workflow: use BOT_PUSH_TOKEN for generated updates + +2026-04-03 - workflow: remove README diff check from test matrix + +2026-04-03 - WebAuth: bump version to 0.3.0 + +2026-04-03 - WebAuth: lazily restore compiled attribute regexes + +2026-04-03 - WebAuth: precompile configured regexes per device + +2026-04-03 - WebAuth: resolve trustedProxy hostnames via DNS + +2026-04-03 - WebAuth: precompile trustedProxy and noCheckFor regexes per device + 2026-04-03 - Merge pull request #8 from fhem/codex/webauth-verbose4-auth-debug WebAuth: add verbose 4 troubleshooting logs for auth debugging diff --git a/FHEM/98_WebAuth.pm b/FHEM/98_WebAuth.pm index 56045e3..d501b1f 100644 --- a/FHEM/98_WebAuth.pm +++ b/FHEM/98_WebAuth.pm @@ -5,20 +5,22 @@ # authenticate FHEMWEB requests based on HTTP headers # # Author: Sidey -# Version: 0.2.0 +# Version: 0.3.0 # package main; use strict; use warnings; +use Socket (); + use FHEM::Core::Authentication::HeaderPolicy qw( evaluate_header_auth_policy parse_header_auth_policy validate_header_auth_policy ); -our $VERSION = '0.2.0'; +our $VERSION = '0.3.0'; ##################################### sub WebAuth_Initialize { @@ -122,20 +124,24 @@ sub Authenticate { return &$doReturn(2); } - my $exc = main::AttrVal($aName, "noCheckFor", undef); - if($exc && $param->{_Path} =~ m/$exc/) { + my ($excRaw, $exc) = _GetStoredRegex($me, $aName, "noCheckFor"); + if($exc && $param->{_Path} =~ $exc) { main::Log3 $aName, 5, "$aName: bypassing authentication for path=$path due to noCheckFor"; return 3; } - my $trustedProxy = main::AttrVal($aName, "trustedProxy", undef); + my ($trustedProxy, $trustedProxyRe) = _GetStoredRegex($me, $aName, "trustedProxy"); if($trustedProxy) { - if(!defined($cl->{PEER}) || $cl->{PEER} !~ m/$trustedProxy/) { + my ($trustedProxyMatched, $peerHostname) = _TrustedProxyMatches($cl->{PEER}, $trustedProxyRe, $trustedProxy); + if(!$trustedProxyMatched) { main::Log3 $aName, 5, - "$aName: proxy mismatch for path=$path peer=".(defined($cl->{PEER}) ? $cl->{PEER} : '')." trustedProxy=$trustedProxy"; + "$aName: proxy mismatch for path=$path peer=".(defined($cl->{PEER}) ? $cl->{PEER} : ''). + " peerHostname=".(defined($peerHostname) ? $peerHostname : '')." trustedProxy=$trustedProxy"; return &$doReturn(0); } - main::Log3 $aName, 5, "$aName: trusted proxy matched for path=$path peer=$cl->{PEER}"; + main::Log3 $aName, 5, + "$aName: trusted proxy matched for path=$path peer=$cl->{PEER}". + (defined($peerHostname) ? " peerHostname=$peerHostname" : ''); } my %effectiveHeaders = %{$param}; @@ -301,6 +307,118 @@ sub _ExtractForwardedClientIP { return undef; } +sub _TrustedProxyMatches { + my ($peer, $trustedProxyRe, $trustedProxy) = @_; + + return (0, undef) if(!defined($peer) || $peer eq '' || !defined($trustedProxyRe) || !defined($trustedProxy) || $trustedProxy eq ''); + return (1, undef) if($peer =~ $trustedProxyRe); + + my $peerHostname = _ResolvePeerHostname($peer); + return (1, $peerHostname) if(defined($peerHostname) && $peerHostname =~ $trustedProxyRe); + + my $normalizedPeer = _NormalizeIPAddress($peer); + foreach my $hostname (_LiteralTrustedProxyHostnames($trustedProxy)) { + foreach my $resolvedIp (_ResolveHostnameToIPs($hostname)) { + next if(!defined($resolvedIp)); + return (1, $peerHostname) if(defined($normalizedPeer) && $normalizedPeer eq _NormalizeIPAddress($resolvedIp)); + } + } + + return (0, $peerHostname); +} + +sub _ResolvePeerHostname { + my ($peer) = @_; + + my ($family, $packedAddress) = _ParseIPAddress($peer); + return undef if(!defined($family) || !defined($packedAddress)); + + my $hostname = gethostbyaddr($packedAddress, $family); + return undef if(!defined($hostname) || $hostname eq ''); + + return lc($hostname); +} + +sub _LiteralTrustedProxyHostnames { + my ($trustedProxy) = @_; + + return () if(!defined($trustedProxy)); + + my $candidate = $trustedProxy; + $candidate =~ s/\A\^//; + $candidate =~ s/\$\z//; + + return () if($candidate !~ m/\A(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]+\z/); + return () if($candidate !~ m/[A-Za-z]/); + + return (lc($candidate)); +} + +sub _ResolveHostnameToIPs { + my ($hostname) = @_; + + return () if(!defined($hostname) || $hostname eq ''); + + my ($err, @results) = Socket::getaddrinfo($hostname, undef); + return () if($err); + + my %seen; + my @ips; + foreach my $result (@results) { + next if(ref($result) ne 'HASH'); + my $ip = _SocketAddressToIP($result->{family}, $result->{addr}); + next if(!defined($ip) || $seen{$ip}++); + push @ips, $ip; + } + + return @ips; +} + +sub _SocketAddressToIP { + my ($family, $socketAddress) = @_; + + return undef if(!defined($family) || !defined($socketAddress)); + + if($family == Socket::AF_INET()) { + my (undef, $packedAddress) = Socket::unpack_sockaddr_in($socketAddress); + return Socket::inet_ntop(Socket::AF_INET(), $packedAddress); + } + + if($family == Socket::AF_INET6()) { + my (undef, $packedAddress) = Socket::unpack_sockaddr_in6($socketAddress); + return Socket::inet_ntop(Socket::AF_INET6(), $packedAddress); + } + + return undef; +} + +sub _ParseIPAddress { + my ($ip) = @_; + + my $normalizedIp = _NormalizeIPAddress($ip); + return (undef, undef) if(!defined($normalizedIp)); + + my $packedIPv4 = Socket::inet_pton(Socket::AF_INET(), $normalizedIp); + return (Socket::AF_INET(), $packedIPv4) if(defined($packedIPv4)); + + my $packedIPv6 = Socket::inet_pton(Socket::AF_INET6(), $normalizedIp); + return (Socket::AF_INET6(), $packedIPv6) if(defined($packedIPv6)); + + return (undef, undef); +} + +sub _NormalizeIPAddress { + my ($ip) = @_; + + return undef if(!defined($ip) || $ip eq ''); + + $ip =~ s/^\[//; + $ip =~ s/\]$//; + $ip =~ s/%[A-Za-z0-9_.-]+\z//; + + return lc($ip); +} + sub _HeaderValue { my ($headers, $wanted) = @_; @@ -314,6 +432,41 @@ sub _HeaderValue { return undef; } +sub _CompileRegex { + my ($raw) = @_; + + return undef if(!defined($raw)); + + my $compiled = eval { qr/$raw/ }; + return undef if($@); + + return $compiled; +} + +sub _GetStoredRegex { + my ($hash, $devName, $attrName) = @_; + + return (undef, undef) if(ref($hash) ne 'HASH' || !defined($attrName)); + + my $raw = $hash->{".$attrName"}; + if(!defined($raw) && defined($devName)) { + $raw = main::AttrVal($devName, $attrName, undef); + $hash->{".$attrName"} = $raw if(defined($raw)); + } + + return (undef, undef) if(!defined($raw) || $raw eq ''); + + my $compiled = $hash->{".$attrName"."Re"}; + if(!defined($compiled)) { + $compiled = _CompileRegex($raw); + if(defined($compiled)) { + $hash->{".$attrName"."Re"} = $compiled; + main::Log3 $devName, 5, "$devName: lazily compiled $attrName regex from stored attribute value"; + } + } + + return ($raw, $compiled); +} sub Attr { my ($type, $devName, $attrName, @param) = @_; @@ -333,6 +486,7 @@ sub Attr { } } elsif($attrName eq "headerAuthPolicy" || + $attrName eq "noCheckFor" || $attrName eq "trustedProxy" || $attrName eq "validFor") { if($set) { @@ -352,14 +506,16 @@ sub Attr { $hash->{".$attrName"} = $policy; } else { - my $regexOk = eval { '' =~ m/$raw/; 1 }; - return "trustedProxy must be a valid Perl regular expression" - if(!$regexOk); + my $compiled = _CompileRegex($raw); + return "$attrName must be a valid Perl regular expression" + if(!defined($compiled)); $hash->{".$attrName"} = $raw; + $hash->{".$attrName"."Re"} = $compiled; } } } else { delete($hash->{".$attrName"}); + delete($hash->{".$attrName"."Re"}) if($attrName eq "noCheckFor" || $attrName eq "trustedProxy"); } if($attrName eq "validFor") { @@ -463,6 +619,13 @@ sub Attr {
  • trustedProxy
    Regexp of trusted reverse-proxy IP addresses or hostnames.
    The check uses the socket peer address of the TCP connection. + If the regexp does not match the peer IP directly, WebAuth also tries + the reverse-resolved hostname of the peer. For literal hostname + patterns like proxy.example.org or + ^proxy.example.org$, WebAuth additionally resolves the + configured hostname via DNS and compares the resulting IP addresses + with the socket peer.

    + If the peer does not match, WebAuth does not handle the request and lets another authenticator, for example allowed with basicAuth, try next.

    @@ -544,7 +707,7 @@ sub Attr { "abstract": "authentifiziert FHEMWEB Requests anhand von HTTP Headern" } }, - "x_version": "0.2.0" + "x_version": "0.3.0" } =end :application/json;q=META.json diff --git a/README.md b/README.md index ed3e316..00ded55 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Generated from [`FHEM/98_WebAuth.pm`](/home/runner/work/WebAuth/WebAuth/FHEM/98_ - Summary: authenticate FHEMWEB requests based on HTTP headers - Zusammenfassung: authentifiziert FHEMWEB Anfragen anhand von HTTP Headern -- Version: 0.2.0 +- Version: 0.3.0 - Author: Sidey - Keywords: Authentication, Authorization, Header, Reverse Proxy, Trusted Proxy, Forward Auth, SSO, OIDC, Web @@ -46,7 +46,7 @@ Authenticate FHEMWEB requests based on HTTP headers, typically injected by a tru ] } ``` -- `trustedProxy`: Regexp of trusted reverse-proxy IP addresses or hostnames. The check uses the socket peer address of the TCP connection. If the peer does not match, WebAuth does not handle the request and lets another authenticator, for example `allowed` with `basicAuth`, try next. When the peer matches, WebAuth additionally makes the peer IP and a client IP derived from `Forwarded` or `X-Forwarded-For` available to `headerAuthPolicy` via synthetic internal headers. Example: +- `trustedProxy`: Regexp of trusted reverse-proxy IP addresses or hostnames. The check uses the socket peer address of the TCP connection. If the regexp does not match the peer IP directly, WebAuth also tries the reverse-resolved hostname of the peer. For literal hostname patterns like `proxy.example.org` or `^proxy.example.org$`, WebAuth additionally resolves the configured hostname via DNS and compares the resulting IP addresses with the socket peer. If the peer does not match, WebAuth does not handle the request and lets another authenticator, for example `allowed` with `basicAuth`, try next. When the peer matches, WebAuth additionally makes the peer IP and a client IP derived from `Forwarded` or `X-Forwarded-For` available to `headerAuthPolicy` via synthetic internal headers. Example: ```json { @@ -114,6 +114,6 @@ per branch. To add this branch as an update source in FHEM, use: ```text -update add https://raw.githubusercontent.com/fhem/WebAuth/main/controls_WebAuth.txt +update add https://raw.githubusercontent.com/fhem/WebAuth/codex/trusted-proxy-dns/controls_WebAuth.txt ``` diff --git a/controls_WebAuth.txt b/controls_WebAuth.txt index 31d719d..1328da9 100644 --- a/controls_WebAuth.txt +++ b/controls_WebAuth.txt @@ -1,2 +1,2 @@ -UPD 2026-04-03_19:23:13 16821 FHEM/98_WebAuth.pm +UPD 2026-04-03_19:47:56 21608 FHEM/98_WebAuth.pm UPD 2026-03-29_23:56:53 4139 lib/FHEM/Core/Authentication/HeaderPolicy.pm diff --git a/t/FHEM/98_WebAuth/10_authenticate.t b/t/FHEM/98_WebAuth/10_authenticate.t index 3367860..9975c25 100644 --- a/t/FHEM/98_WebAuth/10_authenticate.t +++ b/t/FHEM/98_WebAuth/10_authenticate.t @@ -109,6 +109,7 @@ subtest 'non-matching header policy denies access without basic challenge' => su subtest 'noCheckFor still bypasses header auth' => sub { is(fhem('attr webAuthWEB noCheckFor ^/fhem/icons/favicon$'), U(), 'noCheckFor configured'); + is(ref($defs{webAuthWEB}{'.noCheckForRe'}), 'Regexp', 'noCheckFor regex is precompiled on the device hash'); my $client = make_client(); my %headers = (_Path => '/fhem/icons/favicon'); @@ -119,6 +120,53 @@ subtest 'noCheckFor still bypasses header auth' => sub { is($client->{'.httpAuthHeader'}, U(), 'no auth header is generated for bypass'); is(fhem('deleteattr webAuthWEB noCheckFor'), U(), 'noCheckFor removed again'); + is($defs{webAuthWEB}{'.noCheckForRe'}, U(), 'compiled noCheckFor regex is removed again'); +}; + +subtest 'trustedProxy accepts literal hostname via DNS resolution' => sub { + is(fhem('attr webAuthWEB trustedProxy localhost'), U(), 'trustedProxy hostname configured'); + is(ref($defs{webAuthWEB}{'.trustedProxyRe'}), 'Regexp', 'trustedProxy regex is precompiled on the device hash'); + + my $client = make_client( + PEER => '127.0.0.1', + ); + my %headers = ( + _Path => '/fhem', + 'X-Forwarded-User' => 'demo-user', + 'X-Auth-Source' => 'oauth2-proxy', + ); + + my $ret = Authenticate($client, \%headers); + + is($ret, 1, 'literal trustedProxy hostname resolves to peer IP'); + is($client->{AuthenticatedBy}, 'webAuthWEB', 'WebAuth authenticated the request via hostname-based trustedProxy'); + + is(fhem('deleteattr webAuthWEB trustedProxy'), U(), 'trustedProxy removed again'); + is($defs{webAuthWEB}{'.trustedProxyRe'}, U(), 'compiled trustedProxy regex is removed again'); +}; + +subtest 'stored trustedProxy is compiled lazily after reload-style state loss' => sub { + is(fhem('attr webAuthWEB trustedProxy ^localhost$'), U(), 'trustedProxy hostname regex configured'); + is(ref($defs{webAuthWEB}{'.trustedProxyRe'}), 'Regexp', 'trustedProxy regex starts precompiled'); + + delete $defs{webAuthWEB}{'.trustedProxyRe'}; + is($defs{webAuthWEB}{'.trustedProxyRe'}, U(), 'compiled trustedProxy regex removed to simulate reload gap'); + + my $client = make_client( + PEER => '127.0.0.1', + ); + my %headers = ( + _Path => '/fhem', + 'X-Forwarded-User' => 'demo-user', + 'X-Auth-Source' => 'oauth2-proxy', + ); + + my $ret = Authenticate($client, \%headers); + + is($ret, 1, 'trustedProxy still matches after lazy compilation'); + is(ref($defs{webAuthWEB}{'.trustedProxyRe'}), 'Regexp', 'compiled trustedProxy regex is restored lazily'); + + is(fhem('deleteattr webAuthWEB trustedProxy'), U(), 'trustedProxy removed again'); }; todo 'known follow-up auth handling regression with the current patch' => sub {