|
|
|
|
@ -88,7 +88,7 @@ class InboxWorker implements ShouldQueue
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (! isset($bodyDecoded['id'])) {
|
|
|
|
|
if (! isset($bodyDecoded['id']) || ! isset($bodyDecoded['actor'])) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
|
|
|
|
@ -97,25 +97,28 @@ class InboxWorker implements ShouldQueue
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$claimedActor = self::actorUrl($bodyDecoded['actor']);
|
|
|
|
|
if (! $claimedActor) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
|
|
|
|
$id = Helpers::validateUrl($bodyDecoded['id']);
|
|
|
|
|
$claimedActor = Helpers::validateUrl($claimedActor);
|
|
|
|
|
if (! $keyId || ! $id || ! $claimedActor) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$keyDomain = parse_url($keyId, PHP_URL_HOST);
|
|
|
|
|
$idDomain = parse_url($id, PHP_URL_HOST);
|
|
|
|
|
$actorDomain = parse_url($payload['actor'] ?? '', PHP_URL_HOST);
|
|
|
|
|
$actorDomain = parse_url($claimedActor, PHP_URL_HOST);
|
|
|
|
|
if (
|
|
|
|
|
isset($bodyDecoded['object'])
|
|
|
|
|
&& is_array($bodyDecoded['object'])
|
|
|
|
|
&& isset($bodyDecoded['object']['attributedTo'])
|
|
|
|
|
) {
|
|
|
|
|
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
|
|
|
|
|
if (is_array($attr)) {
|
|
|
|
|
if (isset($attr['id'])) {
|
|
|
|
|
$attr = $attr['id'];
|
|
|
|
|
} else {
|
|
|
|
|
$attr = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
|
|
|
|
|
$attr = self::actorUrl($bodyDecoded['object']['attributedTo']);
|
|
|
|
|
if (! $attr || parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -125,22 +128,26 @@ class InboxWorker implements ShouldQueue
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$actor = Profile::whereKeyId($keyId)->first();
|
|
|
|
|
if (! $actor) {
|
|
|
|
|
$actorUrl = Helpers::pluckval($bodyDecoded['actor']);
|
|
|
|
|
$actor = Helpers::profileFirstOrNew($actorUrl);
|
|
|
|
|
|
|
|
|
|
// Resolve the profile that owns the signing key.
|
|
|
|
|
$signer = Profile::whereKeyId($keyId)->first();
|
|
|
|
|
if (! $signer) {
|
|
|
|
|
$signer = Helpers::profileFirstOrNew($claimedActor);
|
|
|
|
|
}
|
|
|
|
|
if (! $actor) {
|
|
|
|
|
if (! $signer) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// Rebind: the profile resolved by keyId must belong to the keyId host.
|
|
|
|
|
// This rejects a poisoned or stale row whose remote_url host differs
|
|
|
|
|
// from the request's keyId host, so a planted key_id -> attacker key
|
|
|
|
|
// binding cannot authenticate.
|
|
|
|
|
if (parse_url($actor->remote_url, PHP_URL_HOST) !== $keyDomain) {
|
|
|
|
|
|
|
|
|
|
// The key owner MUST be the actor the activity claims to be from.
|
|
|
|
|
// A same-host check is not enough: every account on a multi-user
|
|
|
|
|
// instance shares $keyDomain. This subsumes the old rebind check,
|
|
|
|
|
// since a row whose remote_url is on another host can never equal
|
|
|
|
|
// $claimedActor.
|
|
|
|
|
if (! self::sameActorUrl($signer->remote_url, $claimedActor)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$pkey = openssl_pkey_get_public($actor->public_key);
|
|
|
|
|
|
|
|
|
|
$pkey = openssl_pkey_get_public($signer->public_key);
|
|
|
|
|
if (! $pkey) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
@ -152,4 +159,54 @@ class InboxWorker implements ShouldQueue
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extract an actor URL from a string, a {"id": ...} object, or a list.
|
|
|
|
|
*/
|
|
|
|
|
protected static function actorUrl($val)
|
|
|
|
|
{
|
|
|
|
|
$val = Helpers::pluckval($val);
|
|
|
|
|
if (is_array($val)) {
|
|
|
|
|
$val = $val['id'] ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return is_string($val) && $val !== '' ? $val : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Exact actor identity match. Scheme and host are case-insensitive,
|
|
|
|
|
* path is not, a single trailing slash is ignored. Query and fragment
|
|
|
|
|
* are part of the comparison so they cannot be used to alias an actor.
|
|
|
|
|
*/
|
|
|
|
|
protected static function sameActorUrl($a, $b)
|
|
|
|
|
{
|
|
|
|
|
$a = self::normalizeUrl($a);
|
|
|
|
|
$b = self::normalizeUrl($b);
|
|
|
|
|
|
|
|
|
|
return $a !== null && $b !== null && $a === $b;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected static function normalizeUrl($url)
|
|
|
|
|
{
|
|
|
|
|
if (! is_string($url) || $url === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
$p = parse_url($url);
|
|
|
|
|
if (! $p || empty($p['scheme']) || empty($p['host'])) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
$out = strtolower($p['scheme']).'://'.strtolower($p['host']);
|
|
|
|
|
if (isset($p['port'])) {
|
|
|
|
|
$out .= ':'.$p['port'];
|
|
|
|
|
}
|
|
|
|
|
$out .= rtrim($p['path'] ?? '', '/');
|
|
|
|
|
if (isset($p['query'])) {
|
|
|
|
|
$out .= '?'.$p['query'];
|
|
|
|
|
}
|
|
|
|
|
if (isset($p['fragment'])) {
|
|
|
|
|
$out .= '#'.$p['fragment'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $out;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|