Skip to content

Commit

Permalink
Type safety = 100%
Browse files Browse the repository at this point in the history
  • Loading branch information
paragonie-security committed Feb 6, 2018
1 parent 8f00f67 commit d88349d
Show file tree
Hide file tree
Showing 3 changed files with 68 additions and 123 deletions.
1 change: 1 addition & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<psalm
stopOnFirstError="false"
useDocblockTypes="true"
totallyTyped="true"
>
<projectFiles>
<directory name="src" />
Expand Down
184 changes: 63 additions & 121 deletions src/AntiCSRF.php
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ public function getLockType(): string
*
* @param string $lockTo
* @return array
*
* @throws \Exception
* @throws \TypeError
*/
public function getTokenArray(string $lockTo = ''): array
{
Expand All @@ -252,6 +255,7 @@ public function getTokenArray(string $lockTo = ''): array
}

if (empty($lockTo)) {
/** @var string $lockTo */
$lockTo = isset($this->server['REQUEST_URI'])
? $this->server['REQUEST_URI']
: '/';
Expand All @@ -269,7 +273,7 @@ public function getTokenArray(string $lockTo = ''): array
\hash_hmac(
$this->hashAlgo,
isset($this->server['REMOTE_ADDR'])
? $this->server['REMOTE_ADDR']
? (string) $this->server['REMOTE_ADDR']
: '127.0.0.1',
(string) Base64UrlSafe::decode($token),
true
Expand All @@ -283,99 +287,27 @@ public function getTokenArray(string $lockTo = ''): array
];
}

/**
* Validate a request based on $_SESSION and $_POST data
*
* @return bool
*/
public function validateRequestNative(): bool
{
if (!isset($_SESSION[$this->sessionIndex])) {
// We don't even have a session array initialized
$_SESSION[$this->sessionIndex] = [];
return false;
}

if (
!isset($this->post[$this->formIndex]) ||
!isset($this->post[$this->formToken])
) {
// User must transmit a complete index/token pair
return false;
}

// Let's pull the POST data
$index = $_POST[$this->formIndex];
$token = $_POST[$this->formToken];

if (!isset($_SESSION[$this->sessionIndex][$index])) {
// CSRF Token not found
return false;
}

if (!\is_string($index) || !\is_string($token)) {
return false;
}

// Grab the value stored at $index
$stored = $_SESSION[$this->sessionIndex][$index];

// We don't need this anymore
if ($this->deleteToken($this->session[$this->sessionIndex][$index])) {
unset($_SESSION[$this->sessionIndex][$index]);
}

// Which form action="" is this token locked to?
$lockTo = $this->server['REQUEST_URI'];
if (\preg_match('#/$#', $lockTo)) {
// Trailing slashes are to be ignored
$lockTo = Binary::safeSubstr(
$lockTo,
0,
Binary::safeStrlen($lockTo) - 1
);
}

if (!\hash_equals($lockTo, $stored['lockTo'])) {
// Form target did not match the request this token is locked to!
return false;
}

// This is the expected token value
if ($this->hmac_ip === false) {
// We just stored it wholesale
$expected = $stored['token'];
} else {
// We mixed in the client IP address to generate the output
$expected = Base64UrlSafe::encode(
\hash_hmac(
$this->hashAlgo,
isset($this->server['REMOTE_ADDR'])
? $this->server['REMOTE_ADDR']
: '127.0.0.1',
(string) Base64UrlSafe::decode($stored['token']),
true
)
);
}
return \hash_equals($token, $expected);
}

/**
* Validate a request based on $this->session and $this->post data
*
* @return bool
* @throws \TypeError
*/
public function validateRequest(): bool
{
if ($this->useNativeSession) {
return $this->validateRequestNative();
}

if (!isset($this->session[$this->sessionIndex])) {
// We don't even have a session array initialized
$this->session[$this->sessionIndex] = [];
return false;
if (!isset($_SESSION[$this->sessionIndex])) {
return false;
}
/** @var array<string, array<string, mixed>> $sess */
$sess =& $_SESSION[$this->sessionIndex];
} else {
if (!isset($this->session[$this->sessionIndex])) {
return false;
}
/** @var array<string, array<string, mixed>> $sess */
$sess =& $this->session[$this->sessionIndex];
}

if (
Expand All @@ -387,10 +319,15 @@ public function validateRequest(): bool
}

// Let's pull the POST data
/** @var string $index */
$index = $this->post[$this->formIndex];
/** @var string $token */
$token = $this->post[$this->formToken];
if (!\is_string($index) || !\is_string($token)) {
return false;
}

if (!isset($this->session[$this->sessionIndex][$index])) {
if (!isset($sess[$index])) {
// CSRF Token not found
return false;
}
Expand All @@ -400,14 +337,16 @@ public function validateRequest(): bool
}

// Grab the value stored at $index
$stored = $this->session[$this->sessionIndex][$index];
/** @var array<string, mixed> $stored */
$stored = $sess[$index];

// We don't need this anymore
if ($this->deleteToken($this->session[$this->sessionIndex][$index])) {
unset($this->session[$this->sessionIndex][$index]);
if ($this->deleteToken($sess[$index])) {
unset($sess[$index]);
}

// Which form action="" is this token locked to?
/** @var string $lockTo */
$lockTo = $this->server[$this->lock_type];
if (\preg_match('#/$#', $lockTo)) {
// Trailing slashes are to be ignored
Expand All @@ -418,24 +357,26 @@ public function validateRequest(): bool
);
}

if (!\hash_equals($lockTo, $stored['lockTo'])) {
if (!\hash_equals($lockTo, (string) $stored['lockTo'])) {
// Form target did not match the request this token is locked to!
return false;
}

// This is the expected token value
if ($this->hmac_ip === false) {
// We just stored it wholesale
/** @var string $expected */
$expected = $stored['token'];
} else {
// We mixed in the client IP address to generate the output
/** @var string $expected */
$expected = Base64UrlSafe::encode(
\hash_hmac(
$this->hashAlgo,
isset($this->server['REMOTE_ADDR'])
? $this->server['REMOTE_ADDR']
? (string) $this->server['REMOTE_ADDR']
: '127.0.0.1',
(string) Base64UrlSafe::decode($stored['token']),
(string) Base64UrlSafe::decode((string) $stored['token']),
true
)
);
Expand All @@ -452,6 +393,8 @@ public function validateRequest(): bool
*/
public function reconfigure(array $options = []): self
{
/** @var string $opt */
/** @var string $val */
foreach ($options as $opt => $val) {
switch ($opt) {
case 'formIndex':
Expand All @@ -461,16 +404,17 @@ public function reconfigure(array $options = []): self
case 'recycle_after':
case 'hmac_ip':
case 'expire_old':
/** @psalm-suppress MixedAssignment */
$this->$opt = $val;
break;
case 'hashAlgo':
if (\in_array($val, \hash_algos())) {
$this->hashAlgo = $val;
if (\in_array($val, \hash_algos(), true)) {
$this->hashAlgo = (string) $val;
}
break;
case 'lock_type':
if (\in_array($val,array('REQUEST_URI','PATH_INFO'))) {
$this->lock_type=$val;
if (\in_array($val, array('REQUEST_URI','PATH_INFO'), true)) {
$this->lock_type = (string) $val;
}
break;
}
Expand All @@ -483,6 +427,8 @@ public function reconfigure(array $options = []): self
*
* @param string $lockTo What URI endpoint this is valid for
* @return string[]
* @throws \TypeError
* @throws \Exception
*/
protected function generateToken(string $lockTo): array
{
Expand All @@ -506,13 +452,16 @@ protected function generateToken(string $lockTo): array
Binary::safeStrlen($lockTo) - 1
);
}

if ($this->useNativeSession) {
$_SESSION[$this->sessionIndex][$index] = $new;
$_SESSION[$this->sessionIndex][$index]['lockTo'] = $lockTo;
/** @var array<string, array<string, string|int>> $sess */
$sess =& $_SESSION[$this->sessionIndex];
} else {
$this->session[$this->sessionIndex][$index] = $new;
$this->session[$this->sessionIndex][$index]['lockTo'] = $lockTo;
/** @var array<string, array<string, string|int>> $sess */
$sess =& $this->session[$this->sessionIndex];
}
$sess[$index] = $new;
$sess[$index]['lockTo'] = $lockTo;

$this->recycleTokens();
return [$index, $token];
Expand All @@ -532,29 +481,22 @@ protected function recycleTokens()
}

if ($this->useNativeSession) {
// Sort by creation time
\uasort(
$_SESSION[$this->sessionIndex],
function (array $a, array $b): int {
return (int) ($a['created'] <=> $b['created']);
}
);
while (\count($_SESSION[$this->sessionIndex]) > $this->recycle_after) {
// Let's knock off the oldest one
\array_shift($_SESSION[$this->sessionIndex]);
}
/** @var array<string, array<string, string|int>> $sess */
$sess =& $_SESSION[$this->sessionIndex];
} else {
// Sort by creation time
\uasort(
$this->session[$this->sessionIndex],
function (array $a, array $b): int {
return (int) ($a['created'] <=> $b['created']);
}
);
while (\count($this->session[$this->sessionIndex]) > $this->recycle_after) {
// Let's knock off the oldest one
\array_shift($this->session[$this->sessionIndex]);
/** @var array<string, array<string, string|int>> $sess */
$sess =& $this->session[$this->sessionIndex];
}
// Sort by creation time
\uasort(
$sess,
function (array $a, array $b): int {
return (int) ($a['created'] <=> $b['created']);
}
);
while (\count($sess) > $this->recycle_after) {
// Let's knock off the oldest one
\array_shift($sess);
}
return $this;
}
Expand Down
6 changes: 4 additions & 2 deletions src/Reusable.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
class Reusable extends AntiCSRF
{
/**
* @var \DateInterval
* @var \DateInterval|null
*/
protected $tokenLifetime = null;

Expand Down Expand Up @@ -54,6 +54,8 @@ protected function buildBasicToken(array $args = []): array
*/
public function reconfigure(array $options = []): AntiCSRF
{
/** @var string $opt */
/** @var \DateInterval $val */
foreach ($options as $opt => $val) {
switch ($opt) {
case 'tokenLifetime':
Expand All @@ -67,7 +69,7 @@ public function reconfigure(array $options = []): AntiCSRF
}

/**
* @param array $token
* @param array<string, string> $token
* @return bool
*/
public function deleteToken(array $token): bool
Expand Down

0 comments on commit d88349d

Please sign in to comment.