Completed
Push — release-2.1 ( 9a64d5...9df441 )
by
unknown
07:54
created

ProxyServer::cacheImage()   C

Complexity

Conditions 7
Paths 9

Size

Total Lines 36
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 21
nc 9
nop 1
dl 0
loc 36
rs 6.7272
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This is a lightweight proxy for serving images, generally meant to be used alongside SSL
5
 *
6
 * Simple Machines Forum (SMF)
7
 *
8
 * @package SMF
9
 * @author Simple Machines http://www.simplemachines.org
10
 * @copyright 2018 Simple Machines and individual contributors
11
 * @license http://www.simplemachines.org/about/smf/license.php BSD
12
 *
13
 * @version 2.1 Beta 4
14
 */
15
16
define('SMF', 'proxy');
17
18
global $proxyhousekeeping;
0 ignored issues
show
Compatibility Best Practice introduced by
Use of global functionality is not recommended; it makes your code harder to test, and less reusable.

Instead of relying on global state, we recommend one of these alternatives:

1. Pass all data via parameters

function myFunction($a, $b) {
    // Do something
}

2. Create a class that maintains your state

class MyClass {
    private $a;
    private $b;

    public function __construct($a, $b) {
        $this->a = $a;
        $this->b = $b;
    }

    public function myFunction() {
        // Do something
    }
}
Loading history...
19
20
/**
21
 * Class ProxyServer
22
 */
23
class ProxyServer
24
{
25
	/** @var bool $enabled Whether or not this is enabled */
26
	protected $enabled;
27
28
	/** @var int $maxSize The maximum size for files to cache */
29
	protected $maxSize;
30
31
	/** @var string $secret A secret code used for hashing */
32
	protected $secret;
33
34
	/** @var string The cache directory */
35
	protected $cache;
36
37
	/** @var int $maxDays until enties get deleted */
38
	protected $maxDays;
39
40
	/**
41
	 * Constructor, loads up the Settings for the proxy
42
	 *
43
	 * @access public
44
	 */
45
	public function __construct()
46
	{
47
		global $image_proxy_enabled, $image_proxy_maxsize, $image_proxy_secret, $cachedir, $sourcedir;
0 ignored issues
show
Compatibility Best Practice introduced by
Use of global functionality is not recommended; it makes your code harder to test, and less reusable.

Instead of relying on global state, we recommend one of these alternatives:

1. Pass all data via parameters

function myFunction($a, $b) {
    // Do something
}

2. Create a class that maintains your state

class MyClass {
    private $a;
    private $b;

    public function __construct($a, $b) {
        $this->a = $a;
        $this->b = $b;
    }

    public function myFunction() {
        // Do something
    }
}
Loading history...
48
49
		require_once(dirname(__FILE__) . '/Settings.php');
50
		require_once($sourcedir . '/Subs.php');
51
52
		// Turn off all error reporting; any extra junk makes for an invalid image.
53
		error_reporting(0);
54
55
		$this->enabled = (bool) $image_proxy_enabled;
56
		$this->maxSize = (int) $image_proxy_maxsize;
57
		$this->secret = (string) $image_proxy_secret;
58
		$this->cache = $cachedir . '/images';
59
		$this->maxDays = 5;
60
	}
61
62
	/**
63
	 * Checks whether the request is valid or not
64
	 *
65
	 * @access public
66
	 * @return bool Whether the request is valid
0 ignored issues
show
Documentation introduced by
Should the return type not be integer|boolean?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
67
	 */
68
	public function checkRequest()
69
	{
70
		if (!$this->enabled)
71
			return false;
72
73
		// Try to create the image cache directory if it doesn't exist
74
		if (!file_exists($this->cache))
75
			if (!mkdir($this->cache) || !copy(dirname($this->cache) . '/index.php', $this->cache . '/index.php'))
76
				return false;
77
78
		// Basic sanity check
79
		$_GET['request'] = filter_var($_GET['request'], FILTER_VALIDATE_URL);
80
81
		// We aren't going anywhere without these
82
		if (empty($_GET['hash']) || empty($_GET['request']))
83
			return false;
84
85
		$hash = $_GET['hash'];
86
		$request = $_GET['request'];
87
88
		if (md5($request . $this->secret) != $hash)
89
			return false;
90
91
		// Attempt to cache the request if it doesn't exist
92
		if (!$this->isCached($request))
93
			return $this->cacheImage($request);
94
95
		return true;
96
	}
97
98
	/**
99
	 * Serves the request
100
	 *
101
	 * @access public
102
	 * @return void
103
	 */
104
	public function serve()
105
	{
106
		$request = $_GET['request'];
107
		$cached_file = $this->getCachedPath($request);
108
		$cached = json_decode(file_get_contents($cached_file), true);
109
110
		// Did we get an error when trying to fetch the image
111
		$response = $this->checkRequest();
112
		if ($response === -1)
113
		{
114
			// Throw a 404
115
			header('HTTP/1.0 404 Not Found');
116
			exit;
117
		}
118
		// Right, image not cached? Simply redirect, then.
119
		if ($response === 0)
120
		{
121
			$this::redirectexit($request);
122
		}
123
124
		$time = time();
125
126
		// Is the cache expired?
127
		if (!$cached || $time - $cached['time'] > ($this->maxDays * 86400))
128
		{
129
			@unlink($cached_file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
130
			if ($this->checkRequest())
131
				$this->serve();
132
			$this::redirectexit($request);
133
		}
134
135
		$eTag = '"' . substr(sha1($request) . $cached['time'], 0, 64) . '"';
136 View Code Duplication
		if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) && strpos($_SERVER['HTTP_IF_NONE_MATCH'], $eTag) !== false)
137
		{
138
			header('HTTP/1.1 304 Not Modified');
139
			exit;
140
		}
141
142
		// Make sure we're serving an image
143
		$contentParts = explode('/', !empty($cached['content_type']) ? $cached['content_type'] : '');
144
		if ($contentParts[0] != 'image')
145
			exit;
146
147
		$max_age = $time - $cached['time'] + (5 * 86400);
148
		header('content-type: ' . $cached['content_type']);
149
		header('content-length: ' . $cached['size']);
150
		header('cache-control: public, max-age=' . $max_age );
151
		header('last-modified: ' . gmdate('D, d M Y H:i:s', $cached['time']) . ' GMT');
152
		header('etag: ' . $eTag);
153
		echo base64_decode($cached['body']);
154
	}
155
156
	/**
157
	 * Returns the request's hashed filepath
158
	 *
159
	 * @access public
160
	 * @param string $request The request to get the path for
161
	 * @return string The hashed filepath for the specified request
162
	 */
163
	protected function getCachedPath($request)
164
	{
165
		return $this->cache . '/' . sha1($request . $this->secret);
166
	}
167
168
	/**
169
	 * Check whether the image exists in local cache or not
170
	 *
171
	 * @access protected
172
	 * @param string $request The image to check for in the cache
173
	 * @return bool Whether or not the requested image is cached
174
	 */
175
	protected function isCached($request)
176
	{
177
		return file_exists($this->getCachedPath($request));
178
	}
179
180
	/**
181
	 * Attempts to cache the image while validating it
182
	 *
183
	 * @access protected
184
	 * @param string $request The image to cache/validate
185
	 * @return int -1 error, 0 too big, 1 valid image
186
	 */
187
	protected function cacheImage($request)
188
	{
189
		$dest = $this->getCachedPath($request);
190
		$ext = strtolower(pathinfo(parse_url($request, PHP_URL_PATH), PATHINFO_EXTENSION));
191
192
		$image = fetch_web_data($request);
193
194
		// Looks like nobody was home
195
		if (empty($image))
196
			return -1;
197
198
		// What kind of file did they give us?
199
		$finfo = finfo_open(FILEINFO_MIME_TYPE);
200
		$mime_type = finfo_buffer($finfo, $image);
201
202
		// SVG needs a little extra care
203
		if ($ext == 'svg' && $mime_type == 'text/plain')
204
			$mime_type = 'image/svg+xml';
205
206
		// Make sure the url is returning an image
207
		if (strpos($mime_type, 'image/') !== 0)
208
			return -1;
209
210
		// Validate the filesize
211
		$size = strlen($image);
212
		if ($size > ($this->maxSize * 1024))
213
			return 0;
214
215
		// Cache it for later
216
		return file_put_contents($dest, json_encode(array(
217
			'content_type' => $mime_type,
218
			'size' => $size,
219
			'time' => time(),
220
			'body' => base64_encode($image),
221
		))) === false ? -1 : 1;
222
	}
223
224
	/**
225
	 * Static helper function to redirect a request
226
	 *
227
	 * @access public
228
	 * @param type $request
229
	 * @return void
230
	 */
231
	static public function redirectexit($request)
0 ignored issues
show
Coding Style introduced by
As per PSR2, the static declaration should come after the visibility declaration.
Loading history...
232
	{
233
		header('Location: ' . $request, false, 301);
0 ignored issues
show
Security Response Splitting introduced by
'Location: ' . $request can contain request data and is used in response header context(s) leading to a potential security vulnerability.

1 path for user data to reach this point

  1. Read from $_GET, and $request is assigned
    in proxy.php on line 106
  2. $request is passed to ProxyServer::redirectexit()
    in proxy.php on line 121

Response Splitting Attacks

Allowing an attacker to set a response header, opens your application to response splitting attacks; effectively allowing an attacker to send any response, he would like.

General Strategies to prevent injection

In general, it is advisable to prevent any user-data to reach this point. This can be done by white-listing certain values:

if ( ! in_array($value, array('this-is-allowed', 'and-this-too'), true)) {
    throw new \InvalidArgumentException('This input is not allowed.');
}

For numeric data, we recommend to explicitly cast the data:

$sanitized = (integer) $tainted;
Loading history...
234
		exit;
235
	}
236
237
	/**
238
	 * Delete all old entries
239
	 *
240
	 * @access public
241
	 * @return void
242
	 */
243
	public function housekeeping()
244
	{
245
		$path = $this->cache . '/';
246
		if ($handle = opendir($path)) {
247
248
			while (false !== ($file = readdir($handle)))
249
			{
250
				$filelastmodified = filemtime($path . $file);
251
252
				if ((time() - $filelastmodified) > ($this->maxDays * 86400))
253
				{
254
				   unlink($path . $file);
255
				}
256
257
			}
258
259
			closedir($handle);
260
		}
261
	}
262
}
263
264
if (empty($proxyhousekeeping))
265
{
266
	$proxy = new ProxyServer();
267
	$proxy->serve();
268
}
269
270
?>