fix: must-fix items for v1.0 release

Security:
- Add SecurityHeadersListener (X-Content-Type-Options, X-Frame-Options,
  CSP, Referrer-Policy, HSTS)
- Replace document.write() with document.documentElement.innerHTML
  in login JS to avoid CSP violations
- Add CSS escaping (|e('css')) to env color values in _style.html.twig
- Document CSRF protection model: nonce serves as CSRF token for POST
  form path (single-use, server-generated, 120s TTL)
- Reduce TOTP verification window from 10 periods (±5 min) to 1 (±30s)
- Remove hardcoded APP_SECRET from bin/franken.sh (now uses env or
  generates random)
- Remove backup code values from debug log output
- Add .env to .gitignore

Bug fixes:
- Fix ->json access on possibly-null  in LoginListener
  (uses null-safe operator ?->)
- Fix validReturn() not checking false from parse_url (could cause
  TypeError on malformed URLs)
- Add isHit() race condition check in AcceptListener and AllowListener
- Add try/finally in Kernel::terminate() so parent::terminate() always
  runs even if persist() throws
- Add input validation to GenerateBackupCodesCommand (reject count < 1)
- Use Response::HTTP_INTERNAL_SERVER_ERROR constant in GetTotpTrait
  instead of literal 500

Docker/CI:
- Explicitly install curl in Docker final image (needed for healthcheck)
- Update workflow tag pattern to v*.*.* (standardize on v-prefix)
- Extract version without v-prefix for Docker image tag
- Remove stale develop branch from CI triggers
- Fix publish.yaml git remote add to use set-url on re-runs

Code quality:
- Add declare(strict_types=1) to all interface files
- Add #[AsCommand] attribute to GenerateBackupCodesCommand
- Fix BackupCodeInterface default count to match implementation (10)
- Lowercase host before TLD lookup in DomainManager
- Expand TLD list with many missing multi-part TLDs (.com.au, .co.jp,
  .com.br, .co.kr, .com.tw, .co.za, etc.) to prevent open redirect
  vulnerabilities
- Disable unused Symfony sessions in framework.yaml

Tests:
- Update DomainManagerTest for corrected TLD parsing (.com.au, .co.jp,
  .com.br now correctly recognized as multi-part)
- Update GetTotpTraitTest for corrected error message
- Update GenerateBackupCodesCommandTest: zero count now throws exception
This commit is contained in:
2026-08-11 16:33:00 -04:00
parent c0bda8aeec
commit d2eb914637
27 changed files with 221 additions and 58 deletions
-2
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- 'main'
- 'develop'
jobs:
docker:
@@ -31,4 +30,3 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:develop
+6 -3
View File
@@ -3,7 +3,7 @@ name: Push Docker
on:
push:
tags:
- '*.*.*'
- 'v*.*.*'
jobs:
docker:
@@ -22,6 +22,10 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build image
uses: docker/build-push-action@v5
with:
@@ -30,5 +34,4 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_TARGET }}:latest
${{ vars.DOCKERHUB_TARGET }}:${{ github.ref_name }}
${{ vars.DOCKERHUB_TARGET }}:${{ steps.version.outputs.VERSION }}
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
SYNC_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
SYNC_TARGET: ${{ vars.SYNC_GITHUB_TARGET }}
run: |
git remote add github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET"
git remote add github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET" 2>/dev/null || git remote set-url github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET"
- name: Push Current Branch
run: |
-2
View File
@@ -4,11 +4,9 @@ on:
push:
branches:
- 'main'
- 'develop'
pull_request:
branches:
- 'main'
- 'develop'
jobs:
test:
+1
View File
@@ -21,3 +21,4 @@
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
.env
+4 -1
View File
@@ -33,9 +33,12 @@ RUN composer dump-env prod --empty
# start creating final image
FROM dunglas/frankenphp:php8.5-trixie
# install APCu
# install APCu and curl (needed for healthcheck)
RUN pecl install apcu && \
docker-php-ext-enable apcu
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# symfony required environment variables
ENV APP_DEBUG=0
+3 -2
View File
@@ -1,13 +1,14 @@
#!/bin/sh
# Dev utility — builds and runs the preauth container locally.
# Not for production use.
# APP_SECRET should be set in your environment or .env file.
docker container rm preauth
docker container rm preauth 2>/dev/null
docker build . -t digitaladapt/preauth:dev
docker run --name preauth \
-e APP_ENV=dev \
-e APP_DEBUG=true \
-e APP_SECRET=f88a1074691c40415be4439345b79f69 \
-e APP_SECRET="${APP_SECRET:-$(openssl rand -hex 16)}" \
-e APP_SHARE_DIR=var/share \
-e DEFAULT_URI=http://localhost \
-v ./var/share:/app/var/share \
+3 -2
View File
@@ -5,5 +5,6 @@ framework:
trusted_proxies: 'private_ranges'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto']
# Note that the session will be started ONLY if you read or write from it.
session: true
# Sessions are disabled — preauth implements its own cookie/cache-based
# session management and does not use Symfony's session subsystem.
session: false
+7 -2
View File
@@ -7,13 +7,16 @@ namespace App\Command;
use App\PersistCache;
use App\Service\BackupCodeInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException;
/** simple console command to generate backup codes
* usage: php bin/console app:generate-backup-codes [count] */
#[AsCommand(name: 'app:generate-backup-codes')]
final class GenerateBackupCodesCommand extends Command
{
public function __construct(
@@ -25,8 +28,7 @@ final class GenerateBackupCodesCommand extends Command
protected function configure(): void
{
$this->setName('app:generate-backup-codes');
$this->setDescription('Generate singleuse backup codes')
$this->setDescription('Generate single-use backup codes')
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
}
@@ -36,6 +38,9 @@ final class GenerateBackupCodesCommand extends Command
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
$this->persistCache->boot();
$count = (int) $input->getArgument('count');
if ($count < 1) {
throw new ConsoleInvalidArgumentException('Count must be a positive integer.');
}
$codes = $this->manager->generate($count);
foreach ($codes as $code) {
$output->writeln($code);
+5 -3
View File
@@ -28,8 +28,10 @@ class Kernel extends BaseKernel
/** @throws InvalidArgumentException */
public function terminate(Request $request, Response $response): void
{
$this->persistCache->persist();
parent::terminate($request, $response);
try {
$this->persistCache->persist();
} finally {
parent::terminate($request, $response);
}
}
}
+6 -1
View File
@@ -37,7 +37,12 @@ final readonly class AcceptListener
$cookieKey = $this->makeCacheKey("cookie_$cookie");
if ($cookie && $this->sessionCache->hasItem($cookieKey)) {
/* cookie sent corresponds to valid existing session */
$id = $this->sessionCache->getItem($cookieKey)->get();
$item = $this->sessionCache->getItem($cookieKey);
if (! $item->isHit()) {
/* race condition: item was removed between hasItem and getItem */
return;
}
$id = $item->get();
$this->logger->debug("has valid cookie-session: $id");
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
+6 -1
View File
@@ -32,7 +32,12 @@ final readonly class AllowListener
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
if ($this->sessionCache->hasItem($ipKey)) {
/* ip address corresponds to valid existing session */
$id = $this->sessionCache->getItem($ipKey)->get();
$item = $this->sessionCache->getItem($ipKey);
if (! $item->isHit()) {
/* race condition: item was removed between hasItem and getItem */
return;
}
$id = $item->get();
$this->logger->debug("has valid ip-session: $id");
$event->setResponse(new Response("hi $id", headers: [
'Content-Type' => 'text/plain',
+11 -2
View File
@@ -24,6 +24,15 @@ use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
/**
* Handles login attempts via X-Preauth header (AJAX) or POST form submission.
*
* CSRF Protection: The nonce field serves as CSRF protection for the POST form
* path. Nonces are server-generated, single-use, and have a 120-second TTL.
* An attacker cannot forge a POST request without first loading the login page
* to obtain a valid nonce, which requires being on the auth subdomain.
* For the AJAX (header) path, the nonce is embedded in the base64url payload.
*/
final readonly class LoginListener
{
use CookieNameTrait;
@@ -81,9 +90,9 @@ final readonly class LoginListener
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse(
$limitReached,
$payload->json ?? true,
$payload?->json ?? true,
$event->getRequest()->getHost(),
$this->makeCacheKey($payload ? $payload->id : '')
$this->makeCacheKey($payload?->id ?? '')
));
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
/**
* Adds security-related HTTP response headers to all responses.
* These headers help protect against XSS, clickjacking, MIME-type
* sniffing, and referrer leakage.
*/
final readonly class SecurityHeadersListener
{
#[AsEventListener(priority: 0)]
public function onKernelResponse(ResponseEvent $event): void
{
if (! $event->isMainRequest()) {
return;
}
$response = $event->getResponse();
$headers = $response->headers;
/* prevent MIME-type sniffing */
$headers->set('X-Content-Type-Options', 'nosniff');
/* prevent clickjacking — this app is never framed */
$headers->set('X-Frame-Options', 'DENY');
/* control referrer information sent to other sites */
$headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
/* Content-Security-Policy — the login page uses inline styles
* and scripts (via Twig includes), so we allow 'unsafe-inline'
* for those. No external resources are loaded. */
$headers->set(
'Content-Security-Policy',
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';"
);
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
$headers->set('Strict-Transport-Security', 'max-age=31536000');
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Exception;
@@ -13,7 +15,7 @@ interface BackupCodeInterface
* @param int $count Number of codes to generate
* @return string[] Generated backup codes
* @throws InvalidArgumentException|Exception */
public function generate(int $count = 0): array;
public function generate(int $count = 10): array;
/** @throws InvalidArgumentException */
public function expire(): void;
+1 -1
View File
@@ -75,7 +75,7 @@ final readonly class BackupCodeManager implements BackupCodeInterface
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
$this->logger->debug("checking backup code '{$backupKey}': " . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
$this->logger->debug('checking backup code: ' . ($backupItem->isHit() ? 'HIT & ' : 'miss & ') . ($backupItem->get() ? 'VALID' : 'invalid'));
if ($backupItem->isHit() && $backupItem->get()) {
$this->logger->debug("valid backup code");
/* mark backup code as spent */
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Service;
interface DomainInterface
+90 -11
View File
@@ -12,19 +12,98 @@ final readonly class DomainManager implements DomainInterface
private const array TLD = [
'ai' => ['com','net','off','org'],
'am' => ['radio'],
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za'],
'at' => ['ac','co','gv','or'],
'au' => ['com','net','org','edu','gov','asn','id'],
'az' => ['com','net','org'],
'bd' => ['com','net','org','gov','mil','ac'],
'br' => ['com','net','org','gov','mil','eco','emp','g12','ind','inf','rec','tur','tv','edu','far','gov','gru','jor','leg','lec','med','nom','not','ppg','pro','psi','pub','slg','srv','tec','tmp','vip','vlog','wiki','zlg'],
'by' => ['com','net','org','gov','mil','of'],
'ca' => ['ab','bc','mb','nb','nf','nl','ns','nt','nu','on','pe','qc','sk','yk'],
'cc' => [],
'cn' => ['com','net','org','gov','edu','ac','bj','sh','tj','cq','he','sx','nm','ln','jl','hl','js','zj','ah','fj','jx','sd','ha','hb','hn','gd','gx','hi','sc','gz','yn','sn','gs','qh','nx','xj','tw','hk','mo'],
'co' => ['com','net','org','gov','mil','edu','arts','firm','info','int','nom','rec','web'],
'com' => ['br','cn','co','de','eu','gr','it','jpn','mex','ru','sa','uk','us','za','au','bh','bo','cn','ec','eg','gt','hk','hn','il','in','jp','kr','kw','lb','lv','my','mx','ng','ni','np','pe','pf','pg','ph','pk','pl','pr','py','sa','sg','sv','tr','tw','ua','uy','ve','vn','ye'],
'de' => ['com'],
'dk' => ['co'],
'ec' => ['com','net','org','gov','mil','edu','fin','med','pro'],
'ee' => ['com','org','pri'],
'eg' => ['com','net','org','gov','edu','mil'],
'es' => ['com','nom','org','edu','gob'],
'eu' => [],
'fi' => ['aland'],
'fm' => ['radio'],
'fr' => ['com','nom','tm','asso','gouv','pol'],
'ge' => ['com','net','org','edu','gov','mil'],
'gg' => ['co','net','org'],
'in' => ['co','firm','gen','ind','net','org'],
'gr' => ['com','net','org','gov','edu','mil'],
'hk' => ['com','net','org','gov','edu','idv'],
'hu' => ['co','2000','privat','sport','tm','erotica','sex','video','info','org','net','gov','edu','mil','press','biz'],
'id' => ['ac','biz','co','desa','go','mil','my','net','or','sch','web'],
'ie' => ['gov'],
'il' => ['ac','co','gov','idf','k12','muni','net','org'],
'in' => ['co','firm','gen','ind','net','org','ac','edu','res','gov','mil'],
'iq' => ['com','net','org','gov','edu','mil'],
'ir' => ['ac','co','gov','id','net','org','sch'],
'is' => ['net','com','org','edu','gov','int'],
'it' => ['ab','ag','al','an','ao','ap','aq','ar','at','av','ba','bg','bi','bl','bn','bo','br','bs','bt','bz','ca','cb','ce','ch','cl','cn','co','cr','cs','ct','cz','en','fc','fe','fg','fi','fm','fr','ge','go','gr','im','is','kr','lc','le','li','lo','lt','lu','mb','mc','me','mi','mn','mo','ms','mt','na','no','nu','or','pa','pc','pd','pe','pg','pi','pn','po','pr','pt','pu','pv','pz','re','rg','ri','rm','rn','ro','sa','si','so','sp','sr','ss','su','sv','ta','te','tn','to','tp','tr','ts','tv','ud','va','vb','vc','ve','vi','vr','vt','vv','edu','gov','abruzzo','basilicata','calabria','campania','emilia-romagna','friuli-ve-giulia','lazio','liguria','lombardia','marche','molise','piemonte','puglia','sardegna','sicilia','toscana','trentino-a-adige','umbria','valle-aosta','veneto'],
'je' => ['co','net','org'],
'mx' => ['com','net','org'],
'net' => ['gb','hu','in','jp','se','uk'],
'nz' => ['co','net','org'],
'org' => ['ae','us'],
'ph' => ['com','net','org'],
'se' => ['com'],
'uk' => ['co','me','org'],
'jo' => ['com','net','org','gov','edu','mil','sch'],
'jp' => ['ac','ad','co','ed','go','gr','lg','ne','or'],
'ke' => ['co','ne','or','ac','go','me','mobi','info','sc','pro'],
'kg' => ['com','net','org','gov','mil','edu'],
'kr' => ['ac','co','go','hs','kg','mil','ms','ne','or','pe','re','seoul','busan','daegu','incheon','gwangju','daejeon','ulsan','gyeonggi','gangwon','chungbuk','chungnam','jeonbuk','jeonnam','gyeongbuk','gyeongnam','jeju','sejong'],
'kz' => ['com','net','org','edu','gov','mil'],
'li' => [],
'lt' => ['gov'],
'lv' => ['com','net','org','edu','gov','mil','id','asn','conf'],
'ly' => ['com','net','org','gov','edu','sch','med','id'],
'ma' => ['co','net','org','gov','press','ac'],
'mk' => ['com','net','org','edu','gov','inf','name','pro'],
'mx' => ['com','net','org','gov','edu','mil'],
'my' => ['com','net','org','gov','edu','mil','name'],
'na' => ['com','net','org','alt','edu','gov','mil','pro'],
'net' => ['gb','hu','in','jp','se','uk','cn','nz'],
'ng' => ['com','net','org','gov','edu','mil','sch','name','gov'],
'ni' => ['ac','co','com','edu','gob','mil','net','nom','org'],
'nl' => ['bv','co'],
'no' => ['fhs','folkebibl','kommune','mil','stat','priv','vgs','dep','kommune'],
'nz' => ['co','net','org','ac','geek','gen','maori','school','parliament','govt','health','mil','crii','archie','geek','govt','health','maori','school'],
'om' => ['com','net','org','gov','edu','med','mil','sch'],
'org' => ['ae','us','lu'],
'pe' => ['com','net','org','gob','edu','mil','nom'],
'ph' => ['com','net','org','gov','edu','mil'],
'pk' => ['com','net','org','fam','biz','edu','gov','web'],
'pl' => ['com','net','org','aid','agro','atm','auto','biz','edu','gmina','gsm','info','mail','miasta','media','mil','ngo','nom','pc','powiat','priv','realestate','rel','sex','shop','sklep','sos','szkola','targi','tm','tourism','travel','turystyka','gov','ap','augov','bedzin','bialystok','bielawa','bierun','boleslawiec','bydgoszcz','bytom','cieszyn','czeladz','czest','dlugoleka','elblag','elk','glogow','gniezno','gorlice','gorzow','grodzisk','grudziadz','ilk','jaworzno','jelenia-gora','jgora','kalisz','kazimierz-dolny','karpacz','kartuzy','kaszuby','katowice','kepno','ketrzyn','klodzko','kobierzyce','kolobrzeg','konin','konskowola','krapkowice','krakow','krasnik','krasno','krosniewice','kutno','lapy','lebork','legnica','lezajsk','limanowa','lomza','lowicz','lubin','lukow','malbork','malopolska','mazowsze','mazury','mielec','milicz','mielno','mragowo','naklo','nowaruda','nysa','olawa','olecko','olkusz','olsztyn','opoczno','opole','ostrowiec','ostroleka','ostrowwlkp','pila','pisz','podhale','podlasie','polkowice','pomorze','pomorse','prochowice','pruszkow','przeworsk','pulawy','rabka','rawa-maz','rybnik','rzeszow','sanok','sejny','siedlce','slask','slupsk','sosnowiec','stalowa-wola','skoczow','starachowice','stargard','suwalki','swidnica','swiebodzin','swinoujscie','szczecin','szczytno','tarnobrzeg','tgory','turek','tychy','ustka','walbrzych','warmia','warszawa','waw','wegrow','wielun','wlocl','wloclawek','wodzislaw','wolomin','wroclaw','zachpomor','zagan','zarow','zgora','zgorzelec','plug'],
'pr' => ['ac','co','edu','gov','info','island','pro','net','org'],
'pt' => ['com','net','org','gov','edu','int','publ'],
'py' => ['com','net','org','gov','edu','mil','co'],
'qa' => ['com','net','org','gov','edu','mil','sch','name'],
'ro' => ['com','net','org','nom','rec','info','arts','com','firm','tm','www','store','nt','ngo','pro','tm','com','arts','rec','store','info','nom','nt','org','shop','firm','www','rest','travel','transport','tourism','press','media','medical','med','law','jobs','inst','individual','insinfo','guru','fit','engineering','expert','energy','economy','dot','dog','dev','design','dem','dental','craft','corp','consulting','construction','company','com','club','cloud','coach','city','cinema','church','chat','casino','cars','care','cards','broke','blog','bio','bid','band','auto','audio','attorney','apartments','app','art','archi','architects','arena','architects','associates','attorney','auction','auto','baby','band','bank','bar','bargains','beer','berlin','best','bet','bid','bike','bingo','bio','black','blog','blue','boats','bond','boo','book','boutique','build','builders','business','buzz','cab','cafe','call','cam','camp','capital','care','careers','cars','cash','casino','catering','center','ceo','ceramics','cfd','ch','chat','church','city','claims','cleaning','click','clinic','clothing','cloud','club','coach','codes','coffee','college','community','company','computer','condos','construction','consulting','contact','cooking','cool','country','courses','cpa','craft','credit','creditcard','cricket','cruise','cuisinella','cymru','dabur','dance','date','dating','deals','degree','delivery','democrat','dental','design','dev','diamonds','diet','digital','direct','directory','discount','dog','domains','doos','download','ec','edu','education','energy','engineering','enterprises','equipment','estate','events','exchange','expert','exposed','express','fail','faith','family','fan','farm','fashion','film','finance','financial','fish','fit','fitness','flights','florist','flowers','football','forex','forsale','foundation','fun','fund','furniture','futbol','fyi','gal','gallery','game','garden','gift','gifts','gives','glass','global','gold','golf','graphics','gratis','green','gripe','group','guru','health','healthcare','help','helsinki','here','hiphop','hiv','holdings','holiday','homes','horse','host','hosting','house','how','immo','immobilien','in','industries','info','ink','institute','insure','international','investments','irish','jewelry','kaufen','kids','kim','kitchen','kiwi','kred','land','law','lawyer','legal','lgbt','lifestyle','lighting','limited','limo','link','live','loan','loans','lol','london','love','ltd','ltda','luxury','maison','management','market','marketing','markets','media','memorial','men','menu','miami','mobi','moda','moe','mom','money','monster','mortgage','movie','nagoya','name','navy','net','network','news','ngo','ninja','nyc','observer','okinawa','one','ong','onl','online','ooo','org','organic','osaka','paris','partners','parts','party','photo','photography','photos','pics','pictures','pink','pizza','place','plumbing','plus','poker','porn','press','pro','productions','properties','property','pub','qpon','realtor','realty','recipes','red','rehab','reise','reisen','rent','rentals','repair','report','rest','restaurant','review','reviews','rich','rip','rocks','rodeo','run','saarland','sale','salon','sarl','save','saxo','school','schule','science','services','sex','sexy','sg','shop','shopping','show','singles','site','ski','soccer','social','software','solar','solutions','space','store','stream','studio','study','style','supplies','supply','support','surgery','systems','tax','taxi','team','tech','technology','tennis','thai','tips','tires','tirol','today','tokyo','tools','top','tour','tours','town','toys','trade','trading','training','travel','tube','university','uno','vacations','vegas','ventures','vet','viajes','video','villas','vin','vision','vlaanderen','vodka','vote','voting','voto','voyage','wales','watch','webcam','website','wedding','wien','wiki','win','wine','work','works','world','wtf','xxx','xyz','yoga','yokohama','zone'],
'ru' => ['ac','com','edu','int','net','org','pp','adygeya','altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey'],
'sa' => ['com','net','org','gov','med','pub','edu','sch'],
'sb' => ['com','net','org','edu','gov'],
'sc' => ['com','net','org','gov','edu'],
'se' => ['a','ac','b','bd','brand','c','d','e','f','fh','fhsk','fhv','g','h','i','k','komforb','kommunal','komvux','kunskapsforb','l','lanbib','m','n','naturbruksgymn','o','org','p','parti','pp','press','r','s','t','tm','u','v','w','x','y','z'],
'sg' => ['com','net','org','gov','edu','per'],
'sh' => ['com','net','org','gov','mil','edu'],
'sk' => ['co','com','edu','gov','mil','net','org','nfo'],
'st' => ['co','com','consulado','edu','embaixada','gov','mil','net','org','principe','saotome','store'],
'su' => ['abkhazia','adygeya','ak', 'altai','amur','arkhangelsk','astrakhan','bashkiria','belgorod','bir','bryansk','buryatia','cbg','chel','chelyabinsk','chita','chukotka','chuvashia','dagestan','dudinka','e-burg','grozny','irkutsk','ivanovo','izhevsk','jar','joshkar-ola','kalmykia','kaluga','kamchatka','karelia','kazan','kchr','kemerovo','khabarovsk','khakassia','khv','kirov','koenigsberg','komi','kostroma','krasnodar','krasnoyarsk','kuban','kurgan','kursk','lipetsk','magadan','mari','mari-el','marine','mil','mordovia','mosreg','msk','murmansk','nalchik','nnov','nov','novosibirsk','nsk','omsk','orenburg','oryol','palana','penza','perm','ptz','rnd','ryazan','sakhalin','samara','saratov','simbirsk','smolensk','spb','stavropol','stv','surgut','tambov','tatarstan','tom','tomsk','tsaritsyn','tsk','tula','tuva','tver','tyumen','udm','udmurtia','ulan-ude','vladikavkaz','vladimir','vladivostok','volgograd','vologda','voronezh','vrn','vyatka','yakutia','yamal','yaroslavl','yevrey','com','net','org','gov','pp','edu'],
'sv' => ['com','edu','gob','org','red'],
'sy' => ['com','net','org','gov','edu','mil','name'],
'th' => ['ac','co','go','in','mi','net','or'],
'tj' => ['ac','biz','co','com','edu','gov','go','info','int','mil','name','net','nic','nom','org','pro','test','web'],
'tn' => ['agrinet','com','defense','edunet','ens','fin','gov','ind','info','intl','min','nat','net','org','perso','rnrt','rns','rnu','tourism','turen'],
'tr' => ['com','net','org','gov','biz','info','mil','edu','tv','bbs','k12','pol','bel','dr','gen','av','bbs','k12','name','tel','nc','web','tsk','bel','pol','edu'],
'tw' => ['com','net','org','edu','gov','mil','idv','game','ebiz','club','gnu'],
'ua' => ['com','net','org','edu','gov','in','at','cn','crimea','dn','dnepropetrovsk','donetsk','dp','if','ivano-frankivsk','kh','kharkov','kherson','khmelnitskiy','kiev','kirovograd','km','kr','ks','kv','lg','lt','lugansk','lutsk','lv','lviv','mk','mk.ua','mykolaiv','net','nikolaev','od','odessa','pl','poltava','rovno','rv','sebastopol','sm','sumy','te','ternopil','uz','uzhgorod','vinnica','vn','volyn','yalta','zaporizhzhe','zhitomir','zp','zt'],
'uk' => ['co','me','org','ltd','plc','net','sch','ac','gov','nhs','police','mod','nhs','parliament'],
'us' => ['ak','al','ar','as','az','ca','co','ct','dc','de','fl','ga','gu','hi','ia','id','il','in','ks','ky','la','ma','md','me','mi','mn','mo','ms','mt','nc','nd','ne','nh','nj','nm','nv','ny','oh','ok','or','pa','pr','ri','sc','sd','tn','tx','ut','vi','vt','va','wa','wi','wv','wy','dni','fed','isa','kids','nsn'],
'uy' => ['com','net','org','gub','mil','edu'],
've' => ['co','com','edu','gob','info','net','org','web'],
'vn' => ['com','net','org','edu','gov','int','ac','biz','info','name','pro','health'],
'yu' => ['ac','co','edu','gov','org'],
'za' => ['ac','alt','co','edu','gov','law','mil','net','ngo','nom','org','school','tm','web'],
];
private bool $subdomainRedirect;
@@ -61,7 +140,7 @@ final readonly class DomainManager implements DomainInterface
if ($this->authBase()) {
$host = parse_url($url, PHP_URL_HOST);
if ($host === null) {
if ($host === null || $host === false || $host === '') {
return false;
}
/* do not send the user to another domain */
@@ -104,7 +183,7 @@ final readonly class DomainManager implements DomainInterface
return null;
}
$parts = explode('.', $host);
$parts = explode('.', strtolower($host));
$keep = $this->baseLength($parts);
$parts = array_slice($parts, -$keep);
return implode('.', $parts);
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\Payload;
+1 -1
View File
@@ -46,7 +46,7 @@ final readonly class LoginManager implements LoginInterface
$payload->scope = Scope::Cookie;
}
if ($this->getTotp()->verify($payload->token, null, 10) ||
if ($this->getTotp()->verify($payload->token, null, 1) ||
$this->backupCodeManager->verifyAndConsume($payload->token)
) {
/* token is correct (TOTP or Backup) */
+2 -1
View File
@@ -7,6 +7,7 @@ namespace App\Trait;
use App\ConfigBag;
use OTPHP\Factory;
use OTPHP\TOTPInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
@@ -29,6 +30,6 @@ trait GetTotpTrait
if ($otp instanceof TOTPInterface) {
return $otp;
}
throw new HttpException(500, 'Internal Server Exception');
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
}
}
+1 -3
View File
@@ -53,9 +53,7 @@ form.addEventListener('submit', (event) => {
console.log('got html response');
{% endif -%}
response.text().then((html) => {
document.open();
document.write(html);
document.close();
document.documentElement.innerHTML = html;
}).catch((error) => {
console.log('failed to get html from response');
console.log(error);
+2 -2
View File
@@ -1,10 +1,10 @@
<style id="preauth-style">
* { margin: 0; padding: 0.25em; }
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table;
html { background-color: {{ env.bg_color|e('css') }}; color: {{ env.fg_color|e('css') }}; display: table;
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
body { display: table-cell; vertical-align: middle; }
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
p { color: {{ env.error_color }}; text-align: center; }
p { color: {{ env.error_color|e('css') }}; text-align: center; }
form { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
form div { width: 45%; min-width: 300px; }
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
+3
View File
@@ -1,5 +1,8 @@
{% extends 'base.html.twig' %}
{# The nonce field serves dual purpose: replay prevention AND CSRF protection.
An attacker cannot forge a POST request without a valid nonce, which is
generated server-side per page load and tied to the user's session. #}
{% block content %}
<h1>{{ env.title }}</h1>
<p id="preauth-message">{{ message|default }}</p>
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Unit\Command;
use App\Command\GenerateBackupCodesCommand;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use App\PersistCache;
use App\Service\BackupCodeInterface;
use PHPUnit\Framework\TestCase;
@@ -101,8 +102,9 @@ final class GenerateBackupCodesCommandTest extends TestCase
self::assertSame(0, $exit);
}
public function testZeroCodesOutputsNothing(): void
public function testZeroCodesThrowsException(): void
{
// count must be a positive integer — zero is rejected
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]),
$this->makePersistCache()
@@ -110,16 +112,14 @@ final class GenerateBackupCodesCommandTest extends TestCase
$command->setName('app:generate-backup-codes');
$tester = new CommandTester($command);
$exit = $tester->execute(['count' => 0]);
self::assertSame(0, $exit);
self::assertSame('', trim($tester->getDisplay()));
$this->expectException(\Symfony\Component\Console\Exception\InvalidArgumentException::class);
$tester->execute(['count' => 0]);
}
public function testCommandNameAndDescriptionAreConfigured(): void
{
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]),
$this->makeManagerStub(['dummy']),
$this->makePersistCache()
);
// configuring via the Application runs the protected configure()
+6 -9
View File
@@ -171,29 +171,26 @@ final class DomainManagerTest extends TestCase
public function testMatchesAuthWithComAuTld(): void
{
// com.au is NOT in the TLD table (table has au? no, it doesn't),
// so it's treated as a standard 2-part TLD: base = com.au
// com.au IS in the TLD table (au => [com,...], so *.com.au IS multi-part
$manager = $this->createManager(true, 'auth.example.com.au');
self::assertSame('com.au', $manager->authBase());
self::assertSame('example.com.au', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.com.au'));
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthWithCoJpTld(): void
{
// co.jp is NOT in the TLD table (table has jpn under com, not jp under co)
// so base = co.jp
// co.jp IS in the TLD table (jp => [co,...], so *.co.jp IS multi-part
$manager = $this->createManager(true, 'auth.example.co.jp');
self::assertSame('co.jp', $manager->authBase());
self::assertSame('example.co.jp', $manager->authBase());
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
}
public function testMatchesAuthWithComBrTld(): void
{
// com.br: TLD table has com => [br], meaning *.br.com is multi-part
// but com.br has last=br, TLD['br'] doesn't exist, so base = com.br
// com.br: TLD table has br => [com,...], so *.com.br IS multi-part
$manager = $this->createManager(true, 'auth.example.com.br');
self::assertSame('com.br', $manager->authBase());
self::assertSame('example.com.br', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.com.br'));
}
+1 -1
View File
@@ -105,7 +105,7 @@ final class GetTotpTraitTest extends TestCase
$obj->setConfig($config);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Internal Server Exception');
$this->expectExceptionMessage('Internal Server Error');
$obj->publicGetTotp();
}
}