Proper Accept Header Validation

This commit is contained in:
Diogo Cordeiro 2018-08-03 02:07:31 +01:00
parent 1f77983891
commit f25d8278b1
4 changed files with 223 additions and 84 deletions

View File

@ -35,6 +35,7 @@ date_default_timezone_set('UTC');
// Import required files by the plugin
require_once __DIR__ . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'utils' . DIRECTORY_SEPARATOR . 'discoveryhints.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'utils' . DIRECTORY_SEPARATOR . 'AcceptHeader.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'utils' . DIRECTORY_SEPARATOR . 'explorer.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'utils' . DIRECTORY_SEPARATOR . 'postman.php';
@ -163,27 +164,29 @@ class ActivityPubPlugin extends Plugin
*/
public function onRouterInitialized(URLMapper $m)
{
ActivityPubURLMapperOverwrite::variable(
$m,
'user/:id',
['id' => '[0-9]+'],
'apActorProfile'
);
if (ActivityPubURLMapperOverwrite::should()) {
ActivityPubURLMapperOverwrite::variable(
$m,
'user/:id',
['id' => '[0-9]+'],
'apActorProfile'
);
// Special route for webfinger purposes
ActivityPubURLMapperOverwrite::variable(
$m,
':nickname',
['nickname' => Nickname::DISPLAY_FMT],
'apActorProfile'
);
// Special route for webfinger purposes
ActivityPubURLMapperOverwrite::variable(
$m,
':nickname',
['nickname' => Nickname::DISPLAY_FMT],
'apActorProfile'
);
ActivityPubURLMapperOverwrite::variable(
$m,
'notice/:id',
['id' => '[0-9]+'],
'apNotice'
);
ActivityPubURLMapperOverwrite::variable(
$m,
'notice/:id',
['id' => '[0-9]+'],
'apNotice'
);
}
$m->connect(
'user/:id/liked.json',
@ -944,59 +947,6 @@ class ActivityPubReturn
echo json_encode($res, JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Select content type from HTTP Accept header
*
* @author Maciej Łebkowski <m.lebkowski@gmail.com>
* @param array $mimeTypes Supported Types
* @return array|null of supported mime types sorted | null if none valid
*/
public static function getBestSupportedMimeType($mimeTypes)
{
// XXX: This function needs improvement!
if (!isset($_SERVER['HTTP_ACCEPT'])) {
return null;
}
// This mime type was messing everything, thus the special case
if ($_SERVER['HTTP_ACCEPT'] == 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') {
return true;
}
// Values will be stored in this array
$AcceptTypes = [];
// Accept header is case insensitive, and whitespace isnt important
$accept = strtolower(str_replace(' ', '', $_SERVER['HTTP_ACCEPT']));
// divide it into parts in the place of a ","
$accept = explode(',', $accept);
foreach ($accept as $a) {
// the default quality is 1.
$q = 1;
// check if there is a different quality
if (strpos($a, ';q=')) {
// divide "mime/type;q=X" into two parts: "mime/type" and "X"
list($a, $q) = explode(';q=', $a);
}
// mime-type $a is accepted with the quality $q
// WARNING: $q == 0 means, that mime-type isnt supported!
$AcceptTypes[$a] = $q;
}
arsort($AcceptTypes);
$mimeTypes = array_map('strtolower', $mimeTypes);
// lets check our supported types:
foreach ($AcceptTypes as $mime => $q) {
if ($q && in_array($mime, $mimeTypes)) {
return $mime;
}
}
// no mime-type found
return null;
}
}
/**
@ -1004,19 +954,18 @@ class ActivityPubReturn
*/
class ActivityPubURLMapperOverwrite extends URLMapper
{
/**
* Overwrites a route.
*
* @author Hannes Mannerheim <h@nnesmannerhe.im>
* @param URLMapper $m
* @param string $path
* @param string $paramPatterns
* @param string $newaction
* @return void
*/
public static function variable($m, $path, $paramPatterns, $newaction)
{
$mimes = [
'application/json',
'application/activity+json',
'application/ld+json',
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
];
if (is_null(ActivityPubReturn::getBestSupportedMimeType($mimes))) {
return true;
}
$m->connect($path, array('action' => $newaction), $paramPatterns);
$regex = self::makeRegex($path, $paramPatterns);
foreach ($m->variables as $n => $v) {
@ -1025,4 +974,35 @@ class ActivityPubURLMapperOverwrite extends URLMapper
}
}
}
/**
* Determines whether the route should or not be overwrited.
* If ACCEPT header isn't set false will be returned.
*
* @author Diogo Cordeiro <diogo@fc.up.pt>
* @return boolean true if it should, false otherwise
*/
public static function should()
{
// Do not operate without Accept Header
if (!isset($_SERVER['HTTP_ACCEPT'])) {
return false;
}
$mimes = [
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' => 0,
'application/activity+json' => 1,
'application/json' => 2,
'application/ld+json' => 3
];
$acceptheader = new AcceptHeader($_SERVER['HTTP_ACCEPT']);
foreach ($acceptheader as $ah) {
if (isset($mimes[$ah['raw']])) {
return true;
}
}
return false;
}
}

View File

@ -65,7 +65,7 @@ try {
} catch (AlreadyFulfilledException $e) {
// Notice URI already exists
common_debug('ActivityPub Inbox Create Note: Note already exists: '.$e->getMessage());
ActivityPubReturn::error('Note already exists.',202);
ActivityPubReturn::error('Note already exists.', 202);
} catch (Exception $e) {
common_debug('ActivityPub Inbox Create Note: Failed Create Note: '.$e->getMessage());
ActivityPubReturn::error($e->getMessage());

View File

@ -0,0 +1,43 @@
<?php
require 'AcceptHeader.php';
class ContainerTest extends \PHPUnit_Framework_TestCase
{
public function testHeader1()
{
$acceptHeader = new AcceptHeader('audio/*; q=0.2, audio/basic');
$this->assertEquals('audio/basic', $this->_getMedia($acceptHeader[0]));
$this->assertEquals('audio/*; q=0.2', $this->_getMedia($acceptHeader[1]));
}
public function testHeader2()
{
$acceptHeader = new AcceptHeader('text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5');
$this->assertEquals('text/html; level=1', $this->_getMedia($acceptHeader[0]));
$this->assertEquals('text/html; q=0.7', $this->_getMedia($acceptHeader[1]));
$this->assertEquals('*/*; q=0.5', $this->_getMedia($acceptHeader[2]));
$this->assertEquals('text/html; level=2; q=0.4', $this->_getMedia($acceptHeader[3]));
$this->assertEquals('text/*; q=0.3', $this->_getMedia($acceptHeader[4]));
}
public function testHeader3()
{
$acceptHeader = new AcceptHeader('text/*, text/html, text/html;level=1, */*');
$this->assertEquals('text/html; level=1', $this->_getMedia($acceptHeader[0]));
$this->assertEquals('text/html', $this->_getMedia($acceptHeader[1]));
$this->assertEquals('text/*', $this->_getMedia($acceptHeader[2]));
$this->assertEquals('*/*', $this->_getMedia($acceptHeader[3]));
}
private function _getMedia(array $mediaType)
{
$str = $mediaType['type'] . '/' . $mediaType['subtype'];
if (!empty($mediaType['params'])) {
foreach ($mediaType['params'] as $k => $v) {
$str .= '; ' . $k . '=' . $v;
}
}
return $str;
}
}

116
utils/AcceptHeader.php Normal file
View File

@ -0,0 +1,116 @@
<?php
/**
* Note : Code is released under the GNU LGPL
*
* Please do not change the header of this file
*
* This library is free software; you can redistribute it and/or modify it under the terms of the GNU
* Lesser General Public License as published by the Free Software Foundation; either version 2 of
* the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*
* See the GNU Lesser General Public License for more details.
*/
/**
* The AcceptHeader page will parse and sort the different
* allowed types for the content negociations
*
* @author Pierrick Charron <pierrick@webstart.fr>
*/
class AcceptHeader extends \ArrayObject
{
/**
* Constructor
*
* @param string $header Value of the Accept header
* @return void
*/
public function __construct($header)
{
$acceptedTypes = $this->_parse($header);
usort($acceptedTypes, [$this, '_compare']);
parent::__construct($acceptedTypes);
}
/**
* Parse the accept header and return an array containing
* all the informations about the Accepted types
*
* @param string $header Value of the Accept header
* @return array
*/
private function _parse($data)
{
$array = [];
$items = explode(',', $data);
foreach ($items as $item) {
$elems = explode(';', $item);
$acceptElement = [];
$mime = current($elems);
list($type, $subtype) = explode('/', $mime);
$acceptElement['type'] = trim($type);
$acceptElement['subtype'] = trim($subtype);
$acceptElement['raw'] = $mime;
$acceptElement['params'] = [];
while (next($elems)) {
list($name, $value) = explode('=', current($elems));
$acceptElement['params'][trim($name)] = trim($value);
}
$array[] = $acceptElement;
}
return $array;
}
/**
* Compare two Accepted types with their parameters to know
* if one media type should be used instead of an other
*
* @param array $a The first media type and its parameters
* @param array $b The second media type and its parameters
* @return int
*/
private function _compare($a, $b)
{
$a_q = isset($a['params']['q']) ? floatval($a['params']['q']) : 1.0;
$b_q = isset($b['params']['q']) ? floatval($b['params']['q']) : 1.0;
if ($a_q === $b_q) {
$a_count = count($a['params']);
$b_count = count($b['params']);
if ($a_count === $b_count) {
if ($r = $this->_compareSubType($a['subtype'], $b['subtype'])) {
return $r;
} else {
return $this->_compareSubType($a['type'], $b['type']);
}
} else {
return $a_count < $b_count;
}
} else {
return $a_q < $b_q;
}
}
/**
* Compare two subtypes
*
* @param string $a First subtype to compare
* @param string $b Second subtype to compare
* @return int
*/
private function _compareSubType($a, $b)
{
if ($a === '*' && $b !== '*') {
return 1;
} elseif ($b === '*' && $a !== '*') {
return -1;
} else {
return 0;
}
}
}