Skip to content

Commit 42481fa

Browse files
committed
sharedSession support for better security on shared domains
1 parent 8e6e7e0 commit 42481fa

File tree

3 files changed

+612
-34
lines changed

3 files changed

+612
-34
lines changed

src/base_facebook.php

Lines changed: 113 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,12 @@ abstract class BaseFacebook
120120
/**
121121
* Version.
122122
*/
123-
const VERSION = '3.1.1';
123+
const VERSION = '3.2.0';
124+
125+
/**
126+
* Signed Request Algorithm.
127+
*/
128+
const SIGNED_REQUEST_ALGORITHM = 'HMAC-SHA256';
124129

125130
/**
126131
* Default options for curl.
@@ -129,7 +134,7 @@ abstract class BaseFacebook
129134
CURLOPT_CONNECTTIMEOUT => 10,
130135
CURLOPT_RETURNTRANSFER => true,
131136
CURLOPT_TIMEOUT => 60,
132-
CURLOPT_USERAGENT => 'facebook-php-3.1',
137+
CURLOPT_USERAGENT => 'facebook-php-3.2',
133138
);
134139

135140
/**
@@ -200,6 +205,13 @@ abstract class BaseFacebook
200205
*/
201206
protected $fileUploadSupport = false;
202207

208+
/**
209+
* Indicates if we trust HTTP_X_FORWARDED_* headers.
210+
*
211+
* @var boolean
212+
*/
213+
protected $trustForwarded = false;
214+
203215
/**
204216
* Initialize a Facebook Application.
205217
*
@@ -216,7 +228,9 @@ public function __construct($config) {
216228
if (isset($config['fileUpload'])) {
217229
$this->setFileUploadSupport($config['fileUpload']);
218230
}
219-
231+
if (isset($config['trustForwarded']) && $config['trustForwarded']) {
232+
$this->trustForwarded = true;
233+
}
220234
$state = $this->getPersistentData('state');
221235
if (!empty($state)) {
222236
$this->state = $state;
@@ -934,8 +948,9 @@ protected function parseSignedRequest($signed_request) {
934948
$sig = self::base64UrlDecode($encoded_sig);
935949
$data = json_decode(self::base64UrlDecode($payload), true);
936950

937-
if (strtoupper($data['algorithm']) !== 'HMAC-SHA256') {
938-
self::errorLog('Unknown algorithm. Expected HMAC-SHA256');
951+
if (strtoupper($data['algorithm']) !== self::SIGNED_REQUEST_ALGORITHM) {
952+
self::errorLog(
953+
'Unknown algorithm. Expected ' . self::SIGNED_REQUEST_ALGORITHM);
939954
return null;
940955
}
941956

@@ -950,6 +965,26 @@ protected function parseSignedRequest($signed_request) {
950965
return $data;
951966
}
952967

968+
/**
969+
* Makes a signed_request blob using the given data.
970+
*
971+
* @param array The data array.
972+
* @return string The signed request.
973+
*/
974+
protected function makeSignedRequest($data) {
975+
if (!is_array($data)) {
976+
throw new InvalidArgumentException(
977+
'makeSignedRequest expects an array. Got: ' . print_r($data, true));
978+
}
979+
$data['algorithm'] = self::SIGNED_REQUEST_ALGORITHM;
980+
$data['issued_at'] = time();
981+
$json = json_encode($data);
982+
$b64 = self::base64UrlEncode($json);
983+
$raw_sig = hash_hmac('sha256', $b64, $this->getAppSecret(), $raw = true);
984+
$sig = self::base64UrlEncode($raw_sig);
985+
return $sig.'.'.$b64;
986+
}
987+
953988
/**
954989
* Build the URL for api given parameters.
955990
*
@@ -1051,25 +1086,52 @@ protected function getUrl($name, $path='', $params=array()) {
10511086
return $url;
10521087
}
10531088

1089+
protected function getHttpHost() {
1090+
if ($this->trustForwarded && isset($_SERVER['HTTP_X_FORWARDED_HOST'])) {
1091+
return $_SERVER['HTTP_X_FORWARDED_HOST'];
1092+
}
1093+
return $_SERVER['HTTP_HOST'];
1094+
}
1095+
1096+
protected function getHttpProtocol() {
1097+
if ($this->trustForwarded && isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
1098+
if ($_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
1099+
return 'https';
1100+
}
1101+
return 'http';
1102+
}
1103+
if (isset($_SERVER['HTTPS']) &&
1104+
($_SERVER['HTTPS'] === 'on' || $_SERVER['HTTPS'] == 1)) {
1105+
return 'https';
1106+
}
1107+
return 'http';
1108+
}
1109+
1110+
/**
1111+
* Get the base domain used for the cookie.
1112+
*/
1113+
protected function getBaseDomain() {
1114+
// The base domain is stored in the metadata cookie if not we fallback
1115+
// to the current hostname
1116+
$metadata = $this->getMetadataCookie();
1117+
if (array_key_exists('base_domain', $metadata) &&
1118+
!empty($metadata['base_domain'])) {
1119+
return trim($metadata['base_domain'], '.');
1120+
}
1121+
return $this->getHttpHost();
1122+
}
1123+
1124+
/**
1125+
10541126
/**
10551127
* Returns the Current URL, stripping it of known FB parameters that should
10561128
* not persist.
10571129
*
10581130
* @return string The current URL
10591131
*/
10601132
protected function getCurrentUrl() {
1061-
if (isset($_SERVER['HTTPS']) &&
1062-
($_SERVER['HTTPS'] == 'on' || $_SERVER['HTTPS'] == 1) ||
1063-
isset($_SERVER['HTTP_X_FORWARDED_PROTO']) &&
1064-
$_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') {
1065-
$protocol = 'https://';
1066-
}
1067-
else {
1068-
$protocol = 'http://';
1069-
}
1070-
$host = isset($_SERVER['HTTP_X_FORWARDED_HOST'])
1071-
? $_SERVER['HTTP_X_FORWARDED_HOST']
1072-
: $_SERVER['HTTP_HOST'];
1133+
$protocol = $this->getHttpProtocol() . '://';
1134+
$host = $this->getHttpHost();
10731135
$currentUrl = $protocol.$host.$_SERVER['REQUEST_URI'];
10741136
$parts = parse_url($currentUrl);
10751137

@@ -1173,6 +1235,7 @@ protected static function errorLog($msg) {
11731235
* Exactly the same as base64_encode except it uses
11741236
* - instead of +
11751237
* _ instead of /
1238+
* No padded =
11761239
*
11771240
* @param string $input base64UrlEncoded string
11781241
* @return string
@@ -1181,6 +1244,21 @@ protected static function base64UrlDecode($input) {
11811244
return base64_decode(strtr($input, '-_', '+/'));
11821245
}
11831246

1247+
/**
1248+
* Base64 encoding that doesn't need to be urlencode()ed.
1249+
* Exactly the same as base64_encode except it uses
1250+
* - instead of +
1251+
* _ instead of /
1252+
*
1253+
* @param string $input string
1254+
* @return string base64Url encoded string
1255+
*/
1256+
protected static function base64UrlEncode($input) {
1257+
$str = strtr(base64_encode($input), '+/', '-_');
1258+
$str = str_replace('=', '', $str);
1259+
return $str;
1260+
}
1261+
11841262
/**
11851263
* Destroy the current session
11861264
*/
@@ -1196,23 +1274,14 @@ public function destroySession() {
11961274
if (array_key_exists($cookie_name, $_COOKIE)) {
11971275
unset($_COOKIE[$cookie_name]);
11981276
if (!headers_sent()) {
1199-
// The base domain is stored in the metadata cookie if not we fallback
1200-
// to the current hostname
1201-
$base_domain = '.'. $_SERVER['HTTP_HOST'];
1202-
1203-
$metadata = $this->getMetadataCookie();
1204-
if (array_key_exists('base_domain', $metadata) &&
1205-
!empty($metadata['base_domain'])) {
1206-
$base_domain = $metadata['base_domain'];
1207-
}
1208-
1209-
setcookie($cookie_name, '', 0, '/', $base_domain);
1277+
$base_domain = $this->getBaseDomain();
1278+
setcookie($cookie_name, '', 1, '/', '.'.$base_domain);
12101279
} else {
12111280
// @codeCoverageIgnoreStart
12121281
self::errorLog(
12131282
'There exists a cookie that we wanted to clear that we couldn\'t '.
12141283
'clear because headers was already sent. Make sure to do the first '.
1215-
'API call before outputing anything'
1284+
'API call before outputing anything.'
12161285
);
12171286
// @codeCoverageIgnoreEnd
12181287
}
@@ -1250,6 +1319,21 @@ protected function getMetadataCookie() {
12501319
return $metadata;
12511320
}
12521321

1322+
protected static function isAllowedDomain($big, $small) {
1323+
if ($big === $small) {
1324+
return true;
1325+
}
1326+
return self::endsWith($big, '.'.$small);
1327+
}
1328+
1329+
protected static function endsWith($big, $small) {
1330+
$len = strlen($small);
1331+
if ($len === 0) {
1332+
return true;
1333+
}
1334+
return substr($big, -$len) === $small;
1335+
}
1336+
12531337
/**
12541338
* Each of the following four methods should be overridden in
12551339
* a concrete subclass, as they are in the provided Facebook class.

src/facebook.php

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,76 @@
2323
*/
2424
class Facebook extends BaseFacebook
2525
{
26+
const FBSS_COOKIE_NAME = 'fbss';
27+
28+
// We can set this to a high number because the main session
29+
// expiration will trump this.
30+
const FBSS_COOKIE_EXPIRE = 31556926; // 1 year
31+
32+
// Stores the shared session ID if one is set.
33+
protected $sharedSessionID;
34+
2635
/**
2736
* Identical to the parent constructor, except that
2837
* we start a PHP session to store the user ID and
2938
* access token if during the course of execution
3039
* we discover them.
3140
*
32-
* @param Array $config the application configuration.
41+
* @param Array $config the application configuration. Additionally
42+
* accepts "sharedSession" as a boolean to turn on a secondary
43+
* cookie for environments with a shared session (that is, your app
44+
* shares the domain with other apps).
3345
* @see BaseFacebook::__construct in facebook.php
3446
*/
3547
public function __construct($config) {
3648
if (!session_id()) {
3749
session_start();
3850
}
3951
parent::__construct($config);
52+
if (!empty($config['sharedSession'])) {
53+
$this->initSharedSession();
54+
}
4055
}
4156

4257
protected static $kSupportedKeys =
4358
array('state', 'code', 'access_token', 'user_id');
4459

60+
protected function initSharedSession() {
61+
$cookie_name = $this->getSharedSessionCookieName();
62+
if (isset($_COOKIE[$cookie_name])) {
63+
$data = $this->parseSignedRequest($_COOKIE[$cookie_name]);
64+
if ($data && !empty($data['domain']) &&
65+
self::isAllowedDomain($this->getHttpHost(), $data['domain'])) {
66+
// good case
67+
$this->sharedSessionID = $data['id'];
68+
return;
69+
}
70+
// ignoring potentially unreachable data
71+
}
72+
// evil/corrupt/missing case
73+
$base_domain = $this->getBaseDomain();
74+
$this->sharedSessionID = md5(uniqid(mt_rand(), true));
75+
$cookie_value = $this->makeSignedRequest(
76+
array(
77+
'domain' => $base_domain,
78+
'id' => $this->sharedSessionID,
79+
)
80+
);
81+
$_COOKIE[$cookie_name] = $cookie_value;
82+
if (!headers_sent()) {
83+
$expire = time() + self::FBSS_COOKIE_EXPIRE;
84+
setcookie($cookie_name, $cookie_value, $expire, '/', '.'.$base_domain);
85+
} else {
86+
// @codeCoverageIgnoreStart
87+
self::errorLog(
88+
'Shared session ID cookie could not be set! You must ensure you '.
89+
'create the Facebook instance before headers have been sent. This '.
90+
'will cause authentication issues after the first request.'
91+
);
92+
// @codeCoverageIgnoreEnd
93+
}
94+
}
95+
4596
/**
4697
* Provides the implementations of the inherited abstract
4798
* methods. The implementation uses PHP sessions to maintain
@@ -83,11 +134,27 @@ protected function clearAllPersistentData() {
83134
foreach (self::$kSupportedKeys as $key) {
84135
$this->clearPersistentData($key);
85136
}
137+
if ($this->sharedSessionID) {
138+
$this->deleteSharedSessionCookie();
139+
}
140+
}
141+
142+
protected function deleteSharedSessionCookie() {
143+
$cookie_name = $this->getSharedSessionCookieName();
144+
unset($_COOKIE[$cookie_name]);
145+
$base_domain = $this->getBaseDomain();
146+
setcookie($cookie_name, '', 1, '/', '.'.$base_domain);
147+
}
148+
149+
protected function getSharedSessionCookieName() {
150+
return self::FBSS_COOKIE_NAME . '_' . $this->getAppId();
86151
}
87152

88153
protected function constructSessionVariableName($key) {
89-
return implode('_', array('fb',
90-
$this->getAppId(),
91-
$key));
154+
$parts = array('fb', $this->getAppId(), $key);
155+
if ($this->sharedSessionID) {
156+
array_unshift($parts, $this->sharedSessionID);
157+
}
158+
return implode('_', $parts);
92159
}
93160
}

0 commit comments

Comments
 (0)