FritzBox reconfigured with its dedicated per-host login; a real WAN IP change after recycling the connection produced a genuine authenticated update, confirmed in the access log and via dig. First device fully migrated off the legacy shared-key fallback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
22 KiB
alpha.jayfield.org — TODO
Outstanding hardening items (see README.md for the full service overview).
Nothing here is urgent; all are low-risk, no-downtime changes.
-
Mail server IP (
89.58.8.149) listed on Barracuda's BRBL — found 2026-07-25 (user report: "jayfield.org is on the Barracuda MX blacklist"). Confirmed viadig +short 149.8.58.89.b.barracudacentral.org A→127.0.0.2(listed); clean on Spamhaus ZEN, SpamCop, and SORBS — Barracuda-specific. Checked the server for actual abuse before requesting removal: mail queue empty, outbound volume ~11 msgs over the prior week, only known SASL logins (jens@/otto2022@jayfield.orgfrom the usual home IP), no backscatter, correct PTR (alpha.jayfield.org), SPF/DKIM/DMARC (p=reject) all already live — no sign of a compromised account or open relay, so most likely a stale/inherited reputation hit on the IP rather than active abuse from this host. Submitted a removal request viahttps://www.barracudacentral.org/rbl/removal-requeston 2026-07-25. Confirmation number:BBR21785009436-19117-9182. Per Barracuda's response, the IP gets a temporary 48h reputation bump to "normal" while they investigate (up to ~1h to propagate), after which it either stays clear or reverts to listed. Status 2026-07-26: still clear (dig +short 149.8.58.89.b.barracudacentral.org A→ NXDOMAIN, no127.0.0.2) — matches the 1h check (routinetrig_01F1RMhEtNHtAbuHb9ffe1oX, fired 2026-07-25 21:02 UTC, also clear). This is expected during the 48h "normal" bump window, not yet proof of permanent delisting. The real test is the 48h check (routinetrig_019GayXSo5TW5eSxiJ99Ghrp, scheduled 2026-07-27 20:03 UTC) — if it reverts to listed, next step is a manual follow-up to Barracuda citing the confirmation number above. -
dyndns app has no real per-user auth binding — found 2026-07-20, fixed 2026-07-26.
index.php'spass == '_NO_PASS_'check is a hardcoded magic string, not a real per-user credential — nothing tied the single shared Apache Basic Auth identity (jens, the only.htpasswdaccount) to which hostname label got updated. Confirmed live in the access log: a FritzBox ("Fritz!Box DDNS/1.0.3") was authenticating asjenswhile updating theuschirecord — i.e. every device was already relying on this exact gap, self-policing which host it claimed via an unauthenticatedhostname=/user=param. Fixed with real per-host TSIG binding, not just an app-level check: - Added a dedicated.htpasswdaccount for each ofkack,test,uschi,vpn(jens's existing account/password untouched). - Generated 5 per-host TSIG keys (hmac-sha512,tsig-keygen), one per hostname, each in its own/etc/bind/<host>.dyndns.jayfield.org.key(root:bind 640+setfaclgrant forwww-data, same pattern as the original key). - Replaced the zone's flatallow-update { key dyndns.jayfield.org; };innamed.conf.default-zoneswith anupdate-policygranting each new key permission to update only its own name (grant "<host>" name <host>. A AAAA TXT;), plus one explicit legacy fallback grant for the original shared key (subdomainrule, zone-wide) — kept temporarily so unmigrated devices don't break (see below). -nsupdate.php/nsupdate_ipv4.php/nsupdate_ipv6.phpnow sign with the key belonging to$_SERVER['PHP_AUTH_USER'](the authenticated identity), not whatever hostname was requested. If they match, the new per-host key is used — BIND itself then refuses the update if that key isn't granted that name, independent of any PHP-side check. If the authenticated user is specifically the legacyjensaccount and doesn't match, it falls back to the old shared key (logged viasyslog(LOG_WARNING, ...)for migration tracking) — every other account gets rejected outright (-1) on a mismatch, so newly-created per-host logins can never touch another host, even via the fallback. - Also fixed a pre-existing bug found innic/update.phpwhile testing this: it printedgood <ip>unconditionally, without ever checkingnsupdate's return code — so a rejected update silently looked successful to the calling device. Now prints the real dyndns2-protocol response (good <ip>/badauth). All files backed up to/root/removed-configs-backup/dyndns-per-host-auth-20260726094500/. Validated withnamed-checkconf/apache2ctl configtest, applied viarndc reload/systemctl reload apache2. Tested both by drivingnsupdate()directly aswww-dataand end-to-end over HTTPS with the newvpncredentials: own-host updates succeed, cross-host attempts from new accounts are rejected (-1/badauth) with the target record provably unchanged, and the legacyjens→uschipattern still succeeds unchanged. Test IPs used during verification were restored to the real production values (test,uschi,vpn) immediately after; livedigconfirmed the final state matches the pre-change records exactly. Follow-up: thejenslegacy fallback (and its.htpasswdaccount) should stay only until every real device — including whichever ones are still authenticating asjensto updatekack/test/uschi— has been reconfigured with its own dedicated login. Oncedyndns legacy shared-key updatestops appearing insyslog, remove the fallbackgrantand thejensaccount can be scoped down to just its own host too.vpnmigrated 2026-07-26: home FritzBox reconfigured with the newvpnlogin and confirmed live — after a WAN IP change (real connection recycle, not a test), the access log shows a genuineFritz!Box DDNS/1.0.3request authenticated asvpnupdatingvpn.dyndns.jayfield.orgto the new IP (217.229.60.187), anddigconfirms the zone picked it up. First host fully off the legacy fallback.kack/test/uschistill need migrating. -
dyndns/default-vhost access logging is a no-op — done 2026-07-25 found 2026-07-20 while investigating the command injection below.
dyndns.jayfield.org,jayfield.org,www.jayfield.org,mail.jayfield.org,web.jayfield.org, andcloud.jayfield.orghad noCustomLogdirective of their own, andaccess.log(only written bydefault-ssl.conf/000-default.conf) had 0-8 lines — looking like no real vhost was being logged at all. Root cause (2026-07-25 re-check): the fallback assumption was wrong. There's no globalCustomLog access.loginapache2.conf; insteadconf-enabled/other-vhosts-access-log.confenablesmod_vhost_combined, which was already writing every vhost-without-its-own-CustomLog's traffic to/var/log/apache2/other_vhosts_access.log(5,432 lines, confirmed real traffic incl. bot scans againstcloud.jayfield.org). So requests were never actually un-logged — just commingled in one shared, harder-to-grep file instead ofaccess.log. Fixed anyway, since a dedicated per-site log remains the better end state for incident review: added an explicitCustomLog ${APACHE_LOG_DIR}/<site>_access.log combinedto every<VirtualHost>block fordyndns,jayfield.org,www,mail,web,cloud, andportainer(the:80and:443/le-sslblocks of each site share one log file). Configs backed up to/root/removed-configs-backup/vhost-customlog-20260725203959/./etc/logrotate.d/apache2already globs*.log, so no logrotate change needed. Validated withapache2ctl configtest, applied viasystemctl reload apache2, verified live withcurlagainst all 7 domains and confirmed each new<site>_access.logrecorded the hit. -
OS command injection in the dyndns update scripts — done 2026-07-20
/var/www/dyndns/nsupdate.php,nsupdate_ipv4.php, andnsupdate_ipv6.php(used by bothindex.phpandnic/update.php, the router/ddclient-facing endpoint) built a shell command string by directly interpolating unsanitized$_GETinput (hostname/myip/ip, which become$host/$ip/$ipv4/$ipv6) and passed it to PHP'sexec(), which runs it via/bin/sh -c— noescapeshellarg()/escapeshellcmd()anywhere. A request likehostname=$(id>/tmp/pwned).dyndns.jayfield.orgwould have executed arbitrary shell commands aswww-data(the domain-suffix check still passed after the.-split).nic/update.phphad no application-level auth of its own at all. Mitigating factor: the wholedyndns.jayfield.orgvhost sits behind Apache Basic Auth, so this needed valid (or leaked/brute-forced) dyndns credentials to reach — not exploitable by an anonymous visitor. Compounding factors found during the audit: PHP is7.4.33, EOL since Nov 2022,disable_functionswas empty (execfully available);index.php'spass == '_NO_PASS_'check is a hardcoded magic string, not real per-user auth, so any valid dyndns credential can update any of the zone's 5 hostnames (jens,kack,test,uschi,vpn), not just its own — left as-is, out of scope for this fix; and the vhost's access logging turned out to be a no-op (sharedaccess.loghas 0 lines), so past exploitation couldn't be fully ruled out from logs alone — though file mtimes,www-dataprocesses/crontab, and zone contents all looked unremarkable, consistent with unexploited. Fixed: all three files backed up to/root/removed-configs-backup/dyndns-preinjectionfix-<timestamp>/, then rewritten to build the nsupdate command list as a plain string written toproc_open()'s stdin pipe using the array form of the command (array("/usr/bin/nsupdate", "-k", ...)) — this never invokes a shell at all, so no value can be interpreted as shell syntax regardless of content. Added strictpreg_matchvalidation requiring$host/$domainto be shaped like a valid hostname (letters/digits/hyphens, dot-separated labels) andfilter_var(..., FILTER_VALIDATE_IP, FILTER_FLAG_IPV4/IPV6)on the IP before use, as defense in depth on top of removing the shell. Regression found and fixed along the way: functional testing showed legitimate updates were already silently failing (update failed: REFUSED,permission deniedreading the TSIG key) — turned outwww-datawas never granted read access to/etc/bind/dyndns.jayfield.org.keywhen its permissions were tightened to640 root:bindin the item below, so the dyndns web update feature had been broken for real users since 2026-07-19, independent of this vulnerability. Fixed with a narrowly-scoped POSIX ACL (setfacl -m u:www-data:r /etc/bind/dyndns.jayfield.org.key,aclpackage installed) rather than addingwww-datato thebindgroup — the group also ownsrndc.key(full remote BIND control), which would have been a much bigger privilege grant than dyndns needs. Verified viasudo -u www-data phpdriving the real functions directly (same code path as the web UI, without needing the Basic Auth password): two injection payloads ($(...)and;-chained) both rejected (-1, no/tmpside-effect file created), an invalid IP rejected (-1), and a legitimate update succeeded (0), confirmed live in the zone viadig, then cleaned up (test record deleted viansupdate, temp test script removed). Also confirmed unauthenticated requests still get401from Apache before ever reaching the PHP, andapache2ctl configteststayed clean throughout. -
Public static site (
jayfield.org/www.jayfield.org) was 401'ing every visitor — done 2026-07-20 Found while auditing which services are reachable without credentials: a stray/var/www/html/.htaccess(AuthType Basic, sameAuthName "Restricted Content"/AuthUserFile /etc/apache2/.htpasswdas the intentionaldyndns.jayfield.orgprotection) was silently requiring login for the DocumentRoot bothjayfield.organdwww.jayfield.orgshare —/var/wwwhasAllowOverride all, so it took effect with no vhost-level directive needed. File was dated 2025-02-18 (index.html/apache.htmlthemselves are from 2022) — predates every other change in this doc by well over a year, almost certainly a forgotten leftover rather than intentional, and contradictsREADME.md's own description of both as a public static site. Fixed: moved to/root/removed-configs-backup/htaccess-var-www-html.<timestamp>(recoverable, not deleted — same convention as this file's other removed-config backups). No Apache reload needed (.htaccessis read per-request). Verified bothjayfield.organdwww.jayfield.orgnow return200with real page content;dyndns.jayfield.org's own (intentional, vhost-level) Basic Auth is untouched and still returns401without credentials. -
Lock down the dyndns TSIG key file permissions — done 2026-07-19
/etc/bind/dyndns.jayfield.org.keywas644(world-readable), holding the shared secret that authenticates dynamic DNS updates fordyndns.jayfield.orgwith no IP-basedallow-updatebacking it up. Fixed:chmod 640+chown root:bind(owner was already correct), matchingrndc.key's permissions. -
Suppress BIND version disclosure — done 2026-07-19 No
versionstatement innamed.conf.options, sodig CH TXT version.bind @ns1.jayfield.orgleaked the exact BIND version (9.18.39) — a minor fingerprinting aid for attackers. Fixed: addedversion "unknown";inside theoptions {}block, validated withnamed-checkconf, and applied viarndc reload. Verifieddig CH TXT version.bindnow returns"unknown". -
Add response rate limiting (RRL) — done 2026-07-19 No
rate-limit {}block was configured. Sinceallow-query { any; }is required for a public authoritative zone, RRL is the standard mitigation against the server being used for spoofed-source DNS reflection/amplification against third parties. Fixed: addedrate-limit { responses-per-second 10; window 5; }tonamed.conf.options, validated withnamed-checkconf, and applied viarndc reload. Started with conservative defaults (10/s, 5s window) — revisit if legitimate high-volume resolvers get throttled. -
Add HSTS headers to all vhosts — done 2026-07-19 No vhost sent a
Strict-Transport-Securityheader, leaving a window for on-path SSL-stripping on a user's very first plaintext request to any domain. Fixed: addedHeader always set Strict-Transport-Security "max-age=63072000; includeSubDomains"to all 6 domain:443blocks (dyndns,jayfield.org,www,mail,web,cloud) — skippeddefault-ssl.confsince it's the self-signed catch-all with no real trusted hostname for a browser to pin. Validated withapache2ctl configtest, applied viasystemctl reload apache2, verified live withcurl -sIagainst all 6 domains. -
jayfield.orghad no:80vhost of its own — done 2026-07-19 Barehttp://jayfield.orgfell through to thedyndnsdefault vhost and redirected to the wrong domain. Fixed: added a:80block tojayfield.org.confredirecting tohttps://jayfield.org/, matching thedyndns/mailpattern. Verified:curl -sI http://jayfield.org/→302withLocation: https://jayfield.org/. -
SSH: disable root login + password auth — done 2026-07-19
PermitRootLogin yesandPasswordAuthentication yeswere both still enabled (the "known deviation" flagged inSETUP.md§1) — every credential-stuffing bot on the internet got a real login prompt to grind against. Fixed:PermitRootLogin no,PasswordAuthentication no,PubkeyAuthentication yesin/etc/ssh/sshd_config(backed up assshd_config.bak.20260719225249). TheMatch Group sftpblock's ownPasswordAuthentication yeswas left as-is — separate, already-chrooted, no-shell/no-forwarding accounts, not part of this risk. Validated withsshd -t, applied viasystemctl reload sshd, verified live withsshd -Tand a fresh key-only connection before considering it done. -
Portainer's
:9443UI is unreachable in HSTS-enforcing browsers — done 2026-07-20https://alpha.jayfield.org:9443threwMOZILLA_PKIX_ERROR_SELF_SIGNED_CERTin Firefox with no click-through option ("keine Ausnahme kann hinzugefügt werden"). Root cause: theincludeSubDomainsHSTS policy sent by the realalpha.jayfield.orgvhost (Strict-Transport-Securityheader, added in the item above) pins the hostname to HTTPS-with-a-trusted-cert on every port, but Portainer (README.md§Docker services) was still serving its stock self-signed cert directly on9443. Not a MITM — self-inflicted by combining HSTS with an unproxied self-signed service. Fixed: added aportainerA record todb.jayfield.org(SOA serial2022032216→2022032217); recreated theportainercontainer binding the HTTPS UI to127.0.0.1:9443:9443instead of publishing it (old container kept renamed until the new one was verified, then removed, matching the update procedure documented inREADME.md); added an Apache vhost forportainer.jayfield.org(ProxyPass/ProxyPassReversetohttps://127.0.0.1:9443/,SSLProxyEngine on,SSLProxyVerify none+SSLProxyCheckPeerCN off/SSLProxyCheckPeerName offfor the loopback hop to Portainer's self-signed cert) — same pattern asweb.jayfield.org/cloud.jayfield.org(SETUP.md§4/§5); issued a real cert viacertbot --apache -d portainer.jayfield.org --redirect; added the standard HSTS header. Left the8000edge-agent tunnel port published as-is — no evidence it's in use. Verified:openssl s_client/curlconfirm the Let's Encrypt cert (not self-signed) andStrict-Transport-Securityheader onhttps://portainer.jayfield.org,http://redirects 301 tohttps://, andss -ltnpshows9443bound to127.0.0.1only (viadocker-proxy), so it's no longer reachable on the public interface at all — access is now exclusively viahttps://portainer.jayfield.org. One transient wrinkle during rollout: the very first few requests through the new proxy vhost returned a stray401with dyndns's Basic Auth realm ("Restricted Content") — looked alarming, butLogLevel trace8on the vhost showed Apache correctly SNI-matching and proxying to the new container with a clean backend200; resolved itself within seconds (looked like a stale pooled proxy connection surviving the container recreate) and was stable across repeated checks before the debug logging was removed again. -
fail2ban:
sshdjail was evadable via low-and-slow attempts — done 2026-07-19 Defaultfindtime=30m/maxretry=3never triggered for47.76.192.176, which had been hittingroot/jayfieldlogins roughly every 33 minutes for most of a day — just outside the 30-minute window, so failures kept aging out before reaching 3 strikes. Confirmed viafail2ban.log(Found/Bancounts) that no other jail showed the same per-IP timing pattern; a separate, distributed single-shot scan onapache-noscriptwas noted but left open (seeREADME.md). Fixed:/etc/fail2ban/jail.d/sshd-findtime.localoverridessshdtofindtime=1d,maxretry=4,bantime=1d, kept in its own drop-in rather than editing the Debian-packaged jail file. Applied viafail2ban-client reload sshd;47.76.192.176was banned on its very next attempt after the change. -
fail2ban:
dovecotjail had the same low-and-slow gap assshd— done 2026-07-25 found while auditing which IPs were hitting webmail hard enough to deserve a ban but weren't in any currently-banned list. Same defaultfindtime=30m/maxretry=3assshd's pre-fix config. At least 4 IPs (47.95.196.60167 hits,82.157.132.42144,120.48.118.13276,188.164.195.11657,157.245.38.2634) ran a coordinated IMAP password spray against generic mailbox names (admin@,test@,support@,sales@jayfield.org) starting almost simultaneously around 2026-07-21 22:40-23:38, each pacing itself at ~1 attempt/22-25min — just outside the 30-minute window, so none ever tripped 3 strikes.120.48.118.132was still active as late as 2026-07-25 13:32, so the campaign was ongoing, not historical.postfix-saslshares the same default thresholds and mail stack but showed much lower per-IP volume in this pass — worth a follow-up check if it starts trending the same way. Follow-up 2026-07-26: checkedpostfix-sasl— still just scattered background noise, not a coordinated campaign. Top offender (172.94.9.203) logged 32 hits but all from late June/early July, none since; current activity is single/double attempts from many different IPs (no repeated per-IP low-and-slow pattern like the dovecot spray).findtime=30m/maxretry=3defaults left as-is — no fix needed unless this changes. Fixed:/etc/fail2ban/jail.d/dovecot-findtime.local, same values as thesshdfix (findtime=1d,maxretry=4,bantime=1d). Applied viafail2ban-client reload dovecot; verified live withfail2ban-client get dovecot findtime/maxretry/bantimeand confirmed the jail's existing ban state (47.88.56.201, 29 total bans) survived the reload.