Completed
Push — release-2.1 ( e55abf...f15ab1 )
by
unknown
08:31
created

Sources/ShowAttachments.php (2 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
/**
4
 * This file handles avatar and attachment requests. The whole point of this file is to reduce the loaded stuff to show an image.
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
if (!defined('SMF'))
17
	die('No direct access...');
18
19
/**
20
 * Downloads an avatar or attachment based on $_GET['attach'], and increments the download count.
21
 * It requires the view_attachments permission.
22
 * It disables the session parser, and clears any previous output.
23
 * It depends on the attachmentUploadDir setting being correct.
24
 * It is accessed via the query string ?action=dlattach.
25
 * Views to attachments do not increase hits and are not logged in the "Who's Online" log.
26
 */
27
function showAttachment()
28
{
29
	global $smcFunc, $modSettings, $maintenance, $context;
30
31
	// Some defaults that we need.
32
	$context['character_set'] = empty($modSettings['global_character_set']) ? (empty($txt['lang_character_set']) ? 'ISO-8859-1' : $txt['lang_character_set']) : $modSettings['global_character_set'];
33
	$context['utf8'] = $context['character_set'] === 'UTF-8';
34
35
	// An early hook to set up global vars, clean cache and other early process.
36
	call_integration_hook('integrate_pre_download_request');
37
38
	// This is done to clear any output that was made before now.
39
	ob_end_clean();
40
41
	if (!empty($modSettings['enableCompressedOutput']) && !headers_sent() && ob_get_length() == 0)
42
	{
43 View Code Duplication
		if (@ini_get('zlib.output_compression') == '1' || @ini_get('output_handler') == 'ob_gzhandler')
44
			$modSettings['enableCompressedOutput'] = 0;
45
46
		else
47
			ob_start('ob_gzhandler');
48
	}
49
50
	if (empty($modSettings['enableCompressedOutput']))
51
	{
52
		ob_start();
53
		header('content-encoding: none');
54
	}
55
56
	// Better handling.
57
	$attachId = isset($_REQUEST['attach']) ? (int) $_REQUEST['attach'] : (int) (isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : 0);
58
59
	// We need a valid ID.
60
	if (empty($attachId))
61
	{
62
		header('HTTP/1.0 404 File Not Found');
63
		die('404 File Not Found');
64
	}
65
66
	// A thumbnail has been requested? madness! madness I say!
67
	$preview = isset($_REQUEST['preview']) ? $_REQUEST['preview'] : (isset($_REQUEST['type']) && $_REQUEST['type'] == 'preview' ? $_REQUEST['type'] : 0);
68
	$showThumb = isset($_REQUEST['thumb']) || !empty($preview);
69
	$attachTopic = isset($_REQUEST['topic']) ? (int) $_REQUEST['topic'] : 0;
70
71
	// No access in strict maintenance mode or you don't have permission to see attachments.
72
	if ((!empty($maintenance) && $maintenance == 2) || !allowedTo('view_attachments'))
73
	{
74
		header('HTTP/1.0 404 File Not Found');
75
		die('404 File Not Found');
76
	}
77
78
	// Use cache when possible.
79
	if (($cache = cache_get_data('attachment_lookup_id-' . $attachId)) != null)
80
		list($file, $thumbFile) = $cache;
81
82
	// Get the info from the DB.
83
	if (empty($file) || empty($thumbFile) && !empty($file['id_thumb']))
84
	{
85
		// Do we have a hook wanting to use our attachment system? We use $attachRequest to prevent accidental usage of $request.
86
		$attachRequest = null;
87
		call_integration_hook('integrate_download_request', array(&$attachRequest));
88
		if (!is_null($attachRequest) && $smcFunc['db_is_resource']($attachRequest))
89
			$request = $attachRequest;
90
91
		else
92
		{
93
			// Make sure this attachment is on this board and load its info while we are at it.
94
			$request = $smcFunc['db_query']('', '
95
				SELECT id_folder, filename, file_hash, fileext, id_attach, id_thumb, attachment_type, mime_type, approved, id_msg
96
				FROM {db_prefix}attachments
97
				WHERE id_attach = {int:attach}
98
				LIMIT 1',
99
				array(
100
					'attach' => $attachId,
101
				)
102
			);
103
		}
104
105
		// No attachment has been found.
106
		if ($smcFunc['db_num_rows']($request) == 0)
107
		{
108
			header('HTTP/1.0 404 File Not Found');
109
			die('404 File Not Found');
110
		}
111
112
		$file = $smcFunc['db_fetch_assoc']($request);
113
		$smcFunc['db_free_result']($request);
114
115
		// If theres a message ID stored, we NEED a topic ID.
116
		if (!empty($file['id_msg']) && empty($attachTopic) && empty($preview))
117
		{
118
			header('HTTP/1.0 404 File Not Found');
119
			die('404 File Not Found');
120
		}
121
122
		// Previews doesn't have this info.
123
		if (empty($preview))
124
		{
125
			$request2 = $smcFunc['db_query']('', '
126
				SELECT a.id_msg
127
				FROM {db_prefix}attachments AS a
128
					INNER JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg AND m.id_topic = {int:current_topic})
129
					INNER JOIN {db_prefix}boards AS b ON (b.id_board = m.id_board AND {query_see_board})
130
				WHERE a.id_attach = {int:attach}
131
				LIMIT 1',
132
				array(
133
					'attach' => $attachId,
134
					'current_topic' => $attachTopic,
135
				)
136
			);
137
138
			// The provided topic must match the one stored in the DB for this particular attachment, also.
139
			if ($smcFunc['db_num_rows']($request2) == 0)
140
			{
141
				header('HTTP/1.0 404 File Not Found');
142
				die('404 File Not Found');
143
			}
144
145
			$smcFunc['db_free_result']($request2);
146
		}
147
148
		// set filePath and ETag time
149
		$file['filePath'] = getAttachmentFilename($file['filename'], $attachId, $file['id_folder'], false, $file['file_hash']);
150
		// ensure variant attachment compatibility
151
		$filePath = pathinfo($file['filePath']);
152
		$file['filePath'] = !file_exists($file['filePath']) ? substr($file['filePath'], 0, -(strlen($filePath['extension']) + 1)) : $file['filePath'];
153
		$file['etag'] = '"' . md5_file($file['filePath']) . '"';
154
155
		// now get the thumbfile!
156
		$thumbFile = array();
157
		if (!empty($file['id_thumb']))
158
		{
159
			$request = $smcFunc['db_query']('', '
160
				SELECT id_folder, filename, file_hash, fileext, id_attach, attachment_type, mime_type, approved, id_member
161
				FROM {db_prefix}attachments
162
				WHERE id_attach = {int:thumb_id}
163
				LIMIT 1',
164
				array(
165
					'thumb_id' => $file['id_thumb'],
166
				)
167
			);
168
169
			$thumbFile = $smcFunc['db_fetch_assoc']($request);
170
			$smcFunc['db_free_result']($request);
171
172
			// Got something! replace the $file var with the thumbnail info.
173 View Code Duplication
			if ($thumbFile)
174
			{
175
				$attachId = $thumbFile['id_attach'];
176
177
				// set filePath and ETag time
178
				$thumbFile['filePath'] = getAttachmentFilename($thumbFile['filename'], $attachId, $thumbFile['id_folder'], false, $thumbFile['file_hash']);
179
				$thumbFile['etag'] = '"' . md5_file($thumbFile['filePath']) . '"';
180
			}
181
		}
182
183
		// Cache it.
184
		if (!empty($file) || !empty($thumbFile))
185
			cache_put_data('attachment_lookup_id-' . $file['id_attach'], array($file, $thumbFile), mt_rand(850, 900));
186
	}
187
188
	// Replace the normal file with its thumbnail if it has one!
189
	if (!empty($showThumb) && !empty($thumbFile))
190
		$file = $thumbFile;
191
192
	// No point in a nicer message, because this is supposed to be an attachment anyway...
193
	if (!file_exists($file['filePath']))
194
	{
195
		header((preg_match('~HTTP/1\.[01]~i', $_SERVER['SERVER_PROTOCOL']) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0') . ' 404 Not Found');
196
		header('content-type: text/plain; charset=' . (empty($context['character_set']) ? 'ISO-8859-1' : $context['character_set']));
197
198
		// We need to die like this *before* we send any anti-caching headers as below.
199
		die('File not found.');
200
	}
201
202
	// If it hasn't been modified since the last time this attachment was retrieved, there's no need to display it again.
203
	if (!empty($_SERVER['HTTP_IF_MODIFIED_SINCE']))
204
	{
205
		list($modified_since) = explode(';', $_SERVER['HTTP_IF_MODIFIED_SINCE']);
206
		if (strtotime($modified_since) >= filemtime($file['filePath']))
207
		{
208
			ob_end_clean();
209
210
			// Answer the question - no, it hasn't been modified ;).
211
			header('HTTP/1.1 304 Not Modified');
212
			exit;
213
		}
214
	}
215
216
	// Check whether the ETag was sent back, and cache based on that...
217
	$eTag = '"' . substr($_REQUEST['attach'] . $file['filePath'] . filemtime($file['filePath']), 0, 64) . '"';
218
	if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) && strpos($_SERVER['HTTP_IF_NONE_MATCH'], $eTag) !== false)
219
	{
220
		ob_end_clean();
221
222
		header('HTTP/1.1 304 Not Modified');
223
		exit;
224
	}
225
226
	// If this is a partial download, we need to determine what data range to send
227
	$range = 0;
228
	$size = filesize($file['filePath']);
229
	if (isset($_SERVER['HTTP_RANGE']))
230
	{
231
		list($a, $range) = explode("=", $_SERVER['HTTP_RANGE'], 2);
232
		list($range) = explode(",", $range, 2);
233
		list($range, $range_end) = explode("-", $range);
234
		$range = intval($range);
235
		$range_end = !$range_end ? $size - 1 : intval($range_end);
236
		$new_length = $range_end - $range + 1;
237
	}
238
239
	// Update the download counter (unless it's a thumbnail or resuming an incomplete download).
240
	if ($file['attachment_type'] != 3 && empty($showThumb) && $range === 0)
241
		$smcFunc['db_query']('', '
242
			UPDATE {db_prefix}attachments
243
			SET downloads = downloads + 1
244
			WHERE id_attach = {int:id_attach}',
245
			array(
246
				'id_attach' => $attachId,
247
			)
248
		);
249
250
	// Send the attachment headers.
251
	header('pragma: ');
252
253
	if (!isBrowser('gecko'))
254
		header('content-transfer-encoding: binary');
255
256
	header('expires: ' . gmdate('D, d M Y H:i:s', time() + 525600 * 60) . ' GMT');
257
	header('last-modified: ' . gmdate('D, d M Y H:i:s', filemtime($file['filePath'])) . ' GMT');
258
	header('accept-ranges: bytes');
259
	header('connection: close');
260
	header('etag: ' . $eTag);
0 ignored issues
show
Security Response Splitting introduced by
'etag: ' . $eTag 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 $_REQUEST, and $_REQUEST['attach'] . $file['filePath'] . filemtime($file['filePath']) is passed through substr(), and $eTag is assigned
    in Sources/ShowAttachments.php on line 217

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...
261
262
	// Make sure the mime type warrants an inline display.
263
	if (isset($_REQUEST['image']) && !empty($file['mime_type']) && strpos($file['mime_type'], 'image/') !== 0)
264
		unset($_REQUEST['image']);
265
266
	// Does this have a mime type?
267
	elseif (!empty($file['mime_type']) && (isset($_REQUEST['image']) || !in_array($file['fileext'], array('jpg', 'gif', 'jpeg', 'x-ms-bmp', 'png', 'psd', 'tiff', 'iff'))))
268
		header('content-type: ' . strtr($file['mime_type'], array('image/bmp' => 'image/x-ms-bmp')));
269
270
	else
271
	{
272
		header('content-type: ' . (isBrowser('ie') || isBrowser('opera') ? 'application/octetstream' : 'application/octet-stream'));
273
		if (isset($_REQUEST['image']))
274
			unset($_REQUEST['image']);
275
	}
276
277
	// Convert the file to UTF-8, cuz most browsers dig that.
278
	$utf8name = !$context['utf8'] && function_exists('iconv') ? iconv($context['character_set'], 'UTF-8', $file['filename']) : (!$context['utf8'] && function_exists('mb_convert_encoding') ? mb_convert_encoding($file['filename'], 'UTF-8', $context['character_set']) : $file['filename']);
279
	$disposition = !isset($_REQUEST['image']) ? 'attachment' : 'inline';
280
281
	// Different browsers like different standards...
282
	if (isBrowser('firefox'))
283
		header('content-disposition: ' . $disposition . '; filename*=UTF-8\'\'' . rawurlencode(preg_replace_callback('~&#(\d{3,8});~', 'fixchar__callback', $utf8name)));
284
285
	elseif (isBrowser('opera'))
286
		header('content-disposition: ' . $disposition . '; filename="' . preg_replace_callback('~&#(\d{3,8});~', 'fixchar__callback', $utf8name) . '"');
287
288
	elseif (isBrowser('ie'))
289
		header('content-disposition: ' . $disposition . '; filename="' . urlencode(preg_replace_callback('~&#(\d{3,8});~', 'fixchar__callback', $utf8name)) . '"');
290
291
	else
292
		header('content-disposition: ' . $disposition . '; filename="' . $utf8name . '"');
293
294
	// If this has an "image extension" - but isn't actually an image - then ensure it isn't cached cause of silly IE.
295
	if (!isset($_REQUEST['image']) && in_array($file['fileext'], array('gif', 'jpg', 'bmp', 'png', 'jpeg', 'tiff')))
296
		header('cache-control: no-cache');
297
298
	else
299
		header('cache-control: max-age=' . (525600 * 60) . ', private');
300
301
	// Multipart and resuming support
302
	if (isset($_SERVER['HTTP_RANGE']))
303
	{
304
		header("HTTP/1.1 206 Partial Content");
305
		header("content-length: $new_length");
306
		header("content-range: bytes $range-$range_end/$size");
307
	}
308
	else
309
		header("content-length: " . $size);
0 ignored issues
show
Coding Style Comprehensibility introduced by
The string literal content-length: does not require double quotes, as per coding-style, please use single quotes.

PHP provides two ways to mark string literals. Either with single quotes 'literal' or with double quotes "literal". The difference between these is that string literals in double quotes may contain variables with are evaluated at run-time as well as escape sequences.

String literals in single quotes on the other hand are evaluated very literally and the only two characters that needs escaping in the literal are the single quote itself (\') and the backslash (\\). Every other character is displayed as is.

Double quoted string literals may contain other variables or more complex escape sequences.

<?php

$singleQuoted = 'Value';
$doubleQuoted = "\tSingle is $singleQuoted";

print $doubleQuoted;

will print an indented: Single is Value

If your string literal does not contain variables or escape sequences, it should be defined using single quotes to make that fact clear.

For more information on PHP string literals and available escape sequences see the PHP core documentation.

Loading history...
310
311
312
	// Try to buy some time...
313
	@set_time_limit(600);
314
315
	// For multipart/resumable downloads, send the requested chunk(s) of the file
316
	if (isset($_SERVER['HTTP_RANGE']))
317
	{
318
		while (@ob_get_level() > 0)
319
			@ob_end_clean();
320
321
		// 40 kilobytes is a good-ish amount
322
		$chunksize = 40 * 1024;
323
		$bytes_sent = 0;
324
325
		$fp = fopen($file['filePath'], 'rb');
326
327
		fseek($fp, $range);
328
329
		while (!feof($fp) && (!connection_aborted()) && ($bytes_sent < $new_length))
330
		{
331
			$buffer = fread($fp, $chunksize);
332
			echo($buffer);
333
			flush();
334
			$bytes_sent += strlen($buffer);
335
		}
336
		fclose($fp);
337
	}
338
339
	// Since we don't do output compression for files this large...
340
	elseif ($size > 4194304)
341
	{
342
		// Forcibly end any output buffering going on.
343
		while (@ob_get_level() > 0)
344
			@ob_end_clean();
345
346
		$fp = fopen($file['filePath'], 'rb');
347
		while (!feof($fp))
348
		{
349
			echo fread($fp, 8192);
350
			flush();
351
		}
352
		fclose($fp);
353
	}
354
355
	// On some of the less-bright hosts, readfile() is disabled.  It's just a faster, more byte safe, version of what's in the if.
356
	elseif (@readfile($file['filePath']) === null)
357
		echo file_get_contents($file['filePath']);
358
359
	die();
360
}
361
362
?>