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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -4,11 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -21,3 +21,4 @@
|
||||
/.php-cs-fixer.php
|
||||
/.php-cs-fixer.cache
|
||||
###< friendsofphp/php-cs-fixer ###
|
||||
.env
|
||||
|
||||
+4
-1
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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,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 single‑use 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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ?? '')
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
interface DomainInterface
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\Payload;
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user