Skip to content
academia.sh

Lesson 13 / 14

File Uploads

How a file upload reports trouble through return values and error codes: move_uploaded_file on a file that was not uploaded, exceeding a server limit, and a request emptied by exceeding post_max_size.

Published Updated

In this lesson

  • Measures through which channel moving a file that was not uploaded is heard
  • Observes the $_FILES structure and the error codes by sending files to the built-in server with curl
  • Shows that when post_max_size is exceeded, not even a field carrying an error code is left
Contents

In the Form Handling lesson an invalid value came back from the filter_var function as false, and the request went on with a 200. A file upload uses the same channel, but the request has two parts: the file itself and the status information PHP attaches to it. The question widens as well: when an upload fails, where is that information, and what happens if it never arrives?

M04 covered the HTTP side of an upload, the structure of the multipart body, and M16/K01 covered how a server handles a request; here what is measured is what PHP extracts from that request. An uploaded file is placed in the $_FILES superglobal; each file field has the keys name (the name the client gave), size (in bytes), error (a status code) and tmp_name, which holds the path of the temporary file. Because the temporary path depends on the environment, it is not printed in any output of this lesson.

A Non-Uploaded File Is Heard Through the Return

The only way to move a file to a permanent location is the move_uploaded_file function, and it works only for a file that PHP itself received in that request. The first measurement runs on the command line; no upload has taken place there, so the function should fail even when applied to an ordinary file that exists.

<?php
// channel_upload.php — which channel is heard when a file that was not uploaded is moved
$warnings = [];
set_error_handler(function (int $level) use (&$warnings): bool {
    $warnings[] = $level === E_WARNING ? 'E_WARNING' : 'E_' . $level;
    return true;
});

file_put_contents('source.txt', "hello\n");

try {
    $result = move_uploaded_file('source.txt', 'target.txt');
    $channel = $warnings ? 'warning' : ($result === false ? 'return' : 'silent');
} catch (Throwable $error) {
    $channel = 'halts';
}
printf("%-24s%-10s%s\n", 'non-uploaded file', $channel, 'result ' . var_export($result, true));
printf("%-24s%s\n", 'target created', file_exists('target.txt') ? 'yes' : 'no');
non-uploaded file       return    result false
target created          no

The function returns false; no warning, no exception. The file was not moved to the target. This is the return channel, and it is the channel measured in this lesson: execution goes on as if nothing happened unless the caller asks about the false value. RR15. The measurement shows that the function working only on files PHP received as part of a request is a deliberate security feature: even if the caller supplies a file name by hand, the function does not move it. Why this feature exists and how it is classified is the subject of M24 and M25.

The Structure of an Uploaded File

A real upload is measured with the built-in server and curl -F. The -F flag sends the request with a multipart body, and the @ prefix makes the field’s value be read from a file.

<?php
// upload.php — reports the uploaded file and the field counts
$names = [
    UPLOAD_ERR_OK => 'UPLOAD_ERR_OK',
    UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE',
    UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE',
];

echo 'files=', count($_FILES), ' post=', count($_POST), "\n";
foreach ($_FILES as $field => $file) {
    echo $field, ' name=', $file['name'], ' size=', $file['size'];
    echo ' error=', $names[$file['error']] ?? $file['error'], "\n";
    if ($file['error'] === UPLOAD_ERR_OK) {
        $moved = move_uploaded_file($file['tmp_name'], 'stored.txt');
        echo 'moved=', $moved ? 'true' : 'false', ' size=', filesize('stored.txt'), "\n";
    }
}
# server.sh — uploads a small file to the built-in server
printf 'hello, world.\n' > report.txt
php -S 127.0.0.1:8083 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/upload.php && break; sleep 0.1; done
curl -s -F '[email protected]' -F 'title=first' -w 'status=%{http_code}\n' http://127.0.0.1:8083/upload.php
kill $pid
files=1 post=1
file name=report.txt size=14 error=UPLOAD_ERR_OK
moved=true size=14
status=200

$_FILES saw one file field, and $_POST saw the text field (title). The field name, the file name the client gave, the size in bytes and the UPLOAD_ERR_OK error code arrived; this time move_uploaded_file returned true, because the file was part of this request. This is the only case in which the return value is true: the function accepts a file that came by the right path at the start of execution. RR16. tmp_name is not in this output; its value changes on every run and the lesson does not print it.

The $_FILES Structure and a Field with No File Selected

So far only three keys were read. The whole structure of a file field, with its key names, can be seen with a single script; the tmp_name value is not printed, only the key name is listed. The same script also measures the case where the form has a file field but the user selected no file: in this case the browser sends the field with an empty file name.

<?php
// structure.php — all keys and error codes of the $_FILES fields
foreach ($_FILES as $field => $file) {
    $keys = array_keys($file);
    sort($keys);
    echo $field, ' keys=', implode(',', $keys), "\n";
    echo $field, ' error=', json_encode($file['error']), ' size=', json_encode($file['size']), "\n";
}
echo 'file_present=', isset($_FILES['file']) ? 'yes' : 'no', "\n";
# structure.sh — a filled field and a field with no file selected
printf 'welcome\n' > report.txt
php -S 127.0.0.1:8083 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/structure.php && break; sleep 0.1; done
echo '--- filled field'
curl -s -F '[email protected]' http://127.0.0.1:8083/structure.php
echo '--- no file selected'
curl -s -F 'file=@/dev/null;filename=' http://127.0.0.1:8083/structure.php
kill $pid
--- filled field
file keys=error,full_path,name,size,tmp_name,type
file error=0 size=8
file_present=yes
--- no file selected
file keys=error,full_path,name,size,tmp_name,type
file error=4 size=0
file_present=yes

The filled field gives error 0 and size 8; in the measured PHP 8.5 release the key list contains full_path, name, size, tmp_name, type and error. RR17. The full_path key has existed since PHP 8.1; the lesson shows the key list only for the measured release. In the field with no file selected, error is 4, the value of UPLOAD_ERR_NO_FILE, and size is 0. This is the case where the $_FILES['file'] key exists but no upload took place: isset reports it as yes, so the existence of the key does not show that the upload succeeded. Success is recognized by error being UPLOAD_ERR_OK.

A Multiple-File Array Is Per Key, Not Per Field

When the field name is given as file[], PHP does not create a separate record for each file. Instead it holds the array of files under each key (name, size, error); that is, the structure looks not like one record per file but like one array per key.

<?php
// multiple.php — the structure of a file[] field in $_FILES
$file = $_FILES['file'];
echo 'name_type=', get_debug_type($file['name']), ' count=', count($file['name']), "\n";
foreach ($file['name'] as $index => $name) {
    echo $index, ' ', $name, ' size=', $file['size'][$index], ' error=', $file['error'][$index], "\n";
}
# multiple.sh — uploads two files under a single field name
printf 'one\n' > a.txt
printf 'two two\n' > b.txt
php -S 127.0.0.1:8083 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/multiple.php && break; sleep 0.1; done
curl -s -F 'file[][email protected]' -F 'file[][email protected]' -w 'status=%{http_code}\n' http://127.0.0.1:8083/multiple.php
kill $pid
name_type=array count=2
0 a.txt size=4 error=0
1 b.txt size=8 error=0
status=200

name becomes an array, and the information about each file is read with the same index number from three separate arrays. A $file['name'] read written for a single file tries to use an array as a string in a multiple upload; this is the same mistake as the strlen and array measurement in the Superglobals lesson. RR18. Because the client can change the structure by making the field name file[], the structure is still the client’s choice for code that expects a single file.

A Missing Target Path Makes the Move Raise a Warning

The move function can return false for two separate reasons: the file is not part of this request, or the target cannot be written. The first is silent. The second gives the same return value together with a warning. The script below receives a real upload and tries to move the file to a directory that does not exist; the text of the warning is not printed, only the level names.

<?php
// target.php — an attempt to move the uploaded file to a directory that does not exist
$levels = [];
set_error_handler(function (int $level) use (&$levels): bool {
    $levels[] = $level === E_WARNING ? 'E_WARNING' : 'E_' . $level;
    return true;
});

$result = move_uploaded_file($_FILES['file']['tmp_name'], 'missing_dir/target.txt');
echo 'result=', var_export($result, true), ' warnings=', implode(',', $levels), "\n";
# target.sh — sends the file, the move goes to the missing directory
printf 'welcome\n' > report.txt
php -S 127.0.0.1:8083 -d display_errors=0 >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/target.php && break; sleep 0.1; done
curl -s -F '[email protected]' -w 'status=%{http_code}\n' http://127.0.0.1:8083/target.php
kill $pid
result=false warnings=E_WARNING,E_WARNING
status=200

The function returns false and in addition reports two E_WARNINGs: one says the target could not be opened, the other that the move failed. The same return value had come without a warning for the file that was not uploaded in the first section; here the channel shifts to warning. RR19. This difference is invisible to code that looks at the return value: both are false. Only code that has an error handler or a log file can tell why the move failed; the status code is again 200.

When a Server Limit Is Exceeded

PHP limits uploads with two ini values: upload_max_filesize is the upper bound for a single file, and post_max_size is the upper bound for the whole request. The lesson trusts no default setting; the limit is passed with -d. In the first measurement the single-file limit is kept small and a large file is sent. In the second measurement the file field is not sent at all.

# limit.sh — exceeding the file limit and not sending the file field
head -c 3000 /dev/zero | tr '\0' 'a' > large.txt
php -S 127.0.0.1:8083 -d display_errors=0 -d upload_max_filesize=1K >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/upload.php && break; sleep 0.1; done
echo '--- 3000 bytes, limit 1K'
curl -s -F '[email protected]' -F 'title=second' -w 'status=%{http_code}\n' http://127.0.0.1:8083/upload.php
echo '--- no file field'
curl -s -F 'title=third' -w 'status=%{http_code}\n' http://127.0.0.1:8083/upload.php
kill $pid
--- 3000 bytes, limit 1K
files=1 post=1
file name=large.txt size=0 error=UPLOAD_ERR_INI_SIZE
status=200
--- no file field
files=0 post=1
status=200

When the limit is exceeded, the field is still in $_FILES: its name is kept, size becomes 0 and error becomes UPLOAD_ERR_INI_SIZE. This time the signal arrives in the error code; if the code does not read it, execution goes on as if the file had never come. The request again gives 200. When the file field is not sent at all, $_FILES is empty and $_POST is filled: code that reads $_FILES['file'] directly gets both a warning and null, which is the missing key of the Superglobals lesson.

When storing a file, one should not put the name the client gave directly into a path, should not infer the type from the extension alone, and should not place the permanent location under the document root; the reasoning behind these principles is covered in M16/K01, and their classification and defense procedures in M24 and M25. In this lesson stored.txt was used as a fixed name only to make the measurement repeatable.

The Bounding Measurement

In the previous section there was a field that came back with an error code. If the limit is pulled down further, even that is gone. When post_max_size is exceeded, PHP does not parse the body of the request at all: neither the file nor the text field is left in hand.

# ceiling.sh — what is left when post_max_size is exceeded
head -c 5000 /dev/zero | tr '\0' 'a' > large.txt
php -S 127.0.0.1:8083 -d display_errors=0 -d post_max_size=1K >/dev/null 2>&1 &
pid=$!
for i in $(seq 1 50); do curl -s -o /dev/null http://127.0.0.1:8083/upload.php && break; sleep 0.1; done
curl -s -F '[email protected]' -F 'title=fourth' -w 'status=%{http_code}\n' http://127.0.0.1:8083/upload.php
kill $pid
files=0 post=0
status=200

Both $_FILES and $_POST are empty, and the status code is 200. There is not even a field carrying an error code: the request cannot be told apart from a request that sent no fields at all. RR20. It was measured with display_errors=0; with the setting on, a warning PHP produces at startup may fall into the body, and the lesson does not print that text. This is the lower bound of the channel in the previous section: the return channel works when the value the channel would speak about has arrived. When the value never arrives, there is no error code left to read. The only way for the code to notice this is to test the absence of the fields it expects as a separate condition.

Summary

  • $_FILES carries name, size, error and tmp_name for each file field; move_uploaded_file moves only a file that this request received.
  • The channel measured in this lesson is return: for a file that was not uploaded, move_uploaded_file returns false without a warning.
  • When upload_max_filesize is exceeded, the field stays in $_FILES with size 0 and error UPLOAD_ERR_INI_SIZE; the request still gives 200.
  • If the file field is not sent, $_FILES stays empty and $_POST stays filled.
  • When post_max_size is exceeded, both $_POST and $_FILES are emptied; not even a field carrying an error code is left, and the request looks like an empty request without a signal.

Next Step

In the upload lesson the code gave a script such as upload.php a file it had written itself and got a response. A script pulling another file into itself is a separate operation and brings a new form of the same question: what happens if the file to be pulled in cannot be found? The Including Files lesson measures through which channel the inclusion of a missing file is heard and how that channel changes with the kind of include.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close