Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/workflows/fhem_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions CHANGED
Original file line number Diff line number Diff line change
@@ -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
Expand Down
187 changes: 175 additions & 12 deletions FHEM/98_WebAuth.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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} : '<undef>')." trustedProxy=$trustedProxy";
"$aName: proxy mismatch for path=$path peer=".(defined($cl->{PEER}) ? $cl->{PEER} : '<undef>').
" peerHostname=".(defined($peerHostname) ? $peerHostname : '<undef>')." 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};
Expand Down Expand Up @@ -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) = @_;

Expand All @@ -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) = @_;
Expand All @@ -333,6 +486,7 @@ sub Attr {
}

} elsif($attrName eq "headerAuthPolicy" ||
$attrName eq "noCheckFor" ||
$attrName eq "trustedProxy" ||
$attrName eq "validFor") {
if($set) {
Expand All @@ -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") {
Expand Down Expand Up @@ -463,6 +619,13 @@ sub Attr {
<li>trustedProxy<br>
Regexp of trusted reverse-proxy IP addresses or hostnames.<br>
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 <code>proxy.example.org</code> or
<code>^proxy.example.org$</code>, WebAuth additionally resolves the
configured hostname via DNS and compares the resulting IP addresses
with the socket peer.<br><br>

If the peer does not match, WebAuth does not handle the request and
lets another authenticator, for example <code>allowed</code> with
<code>basicAuth</code>, try next.<br><br>
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -114,6 +114,6 @@ per branch. To add this branch as an update source in FHEM, use:

<!-- BEGIN GENERATED FHEM UPDATE COMMAND -->
```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
```
<!-- END GENERATED FHEM UPDATE COMMAND -->
2 changes: 1 addition & 1 deletion controls_WebAuth.txt
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions t/FHEM/98_WebAuth/10_authenticate.t
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 {
Expand Down