Secure File Download from a path outside of website root

Your workaround worked!

Now I have the next problem:

Thats what I get:

%PDF-1.3 %���� 1 0 obj << /Type /Catalog /Pages 2 0 R /PageMode /UseNone /ViewerPreferences << /FitWindow true /PageLayout /SinglePage /NonFullScreenPageMode /UseNone >> >> endobj 5 0 obj << /Length 2028 /Filter [ /FlateDecode ] >> stream x��X�r

That is great, didn’t think the debounce would work here.

For the download.php, make sure that only the php block is in there and there are no whitespaces before the <?php. Here a better script for the download.

<?php

$file = '/usr/home/vewade' . $_POST['DateiPfad'] . '/' . $_POST['Dateiname'];

if (file_exists($file)) {
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="' . basename($file) . '"');
    header('Expires: 0');
    header('Cache-Control: must-revalidate');
    header('Pragma: public');
    header('Content-Length: ' . filesize($file));
    readfile($file);
} else {
    header("HTTP/1.0 404 Not Found")
}
2 Likes

Hi @patrick,

thanks for your script. I get a Error 500. Any idea what could be the cause?

I got it working!

Here is the code for download.php:

<?php
$file = ('/usr/home/vewade' . $_POST['DateiPfad'] . '/' . $_POST['Dateiname']);

if (file_exists($file)) {
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="'.basename($file).'"');
    header('Expires: 0');
    header('Cache-Control: must-revalidate');
    header('Pragma: public');
    header('Content-Length: ' . filesize($file));
    readfile($file);
  } else {
    header("HTTP/1.0 404 Not Found");
}
?>

Thank you everybody who helped me with this!

Any chance that this will be integrated in Wappler sometime? Or is my approach to exotic? It would be great to have the “if file exist” option.

1 Like

Already has an if file exists option in server connect:

I know, but not for files outside of website root.

Yes, it appears that you cannot just prefix the path with ../ to reference the folder above, not quite sure why that restriction should be there but it breaks the server action if you do

well we build that in as a security measurement long time ago in Server Connect.
Because people use often $_POST/$_GET input data into paths and that will make it so easy for hackers to access files and folders outside the wwwroot with just adding …/…/

You will that have your web server compromised in no time. So you should be really careful in allowing downloads outside website root. If not done well - people might gain full access to the whole system.

2 Likes

Hi @George,

thank you for your feedback. Do you have any security concerns on how I solved my problem?

Yes, security is a concern in your case. As George already mentions a hacker could just post DateiPfad="/../../../etc" and Dateiname="passwd" to get the encrypted passwords file from your server. As extra security you could use realpath (https://www.php.net/manual/en/function.realpath.php) and check if the results starts with /usr/home/vewade. Or you do a database query in the download.php to get the path so that a user can’t manipulate it.

1 Like

Hmm, I should rethink my solution then. Security was my main reason to upload the files outside of the website root. The current solution from webassist does not post the path and the filename. It uses a php script to accomplish the download from the files outside of website root (my post from Jan. 13th). Any chance that you could implement a similar solution as webassist?

I asume you use MySQL. Here something I came up with, didn’t test it and you will need to adjust it to your needs.

<?php

$conn = new mysqli("localhost", "username", "password", "database");
if ($conn->connect_error) die("Connection failed: " . $conn->connect_error);

$stmt = $conn->prepare("SELECT DateiPfad, Dateiname FROM table WHERE DokumentId = ?");
$stmt->bind_param($_POST["DokumentId"]);
$stmt->execute();

$res = $stmt->get_result();
$row = $res->fetch_assoc();

$stmt->close();

$file = ('/usr/home/vewade' . $row['DateiPfad'] . '/' . $row['Dateiname']);

...

The above code is only an example on how to solve it, it will need some extra checks to see if the database returned a result set and return a 404 when record or file is not found. Check the PHP documentation at https://www.php.net/manual/en/mysqli.quickstart.prepared-statements.php, you should use prepared statements to provide protection against SQL injections.

Thanks a lot for your help.

I have the following code, but get a ERROR 500.

<?php

$conn = new mysqli("****.de", "***********", "********", "***********");
if ($conn->connect_error) die("Connection failed: " . $conn->connect_error);

$stmt = $conn->prepare("SELECT DateiPfad, Dateiname FROM ****** WHERE DokumentId = ?");
$stmt->bind_param($_POST["dokid"]);
$stmt->execute();

$res = $stmt->get_result();
$row = $res->fetch_assoc();

$stmt->close();

$file = ('/usr/home/vewade' . $row['DateiPfad'] . '/' . $row['Dateiname']);

if (file_exists($file)) {
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="'.basename($file).'"');
    header('Expires: 0');
    header('Cache-Control: must-revalidate');
    header('Pragma: public');
    header('Content-Length: ' . filesize($file));
    readfile($file);
  } else {
    header("HTTP/1.0 404 Not Found");
}
?>

Any idea, what is missing?

Difficult to tell without the error message. In the link I gave you there are multiple samples of usage, it is possible that the prepare, execute or get_result failed. You should add extra checks for that.

if (!($stmt = $conn->prepare("SELECT DateiPfad, Dateiname FROM ****** WHERE DokumentId = ?"))) {
  die("Prepare failed: (" . $conn->errno . ") " . $conn->error);
}

if (!$stmt->execute()) {
  die("Execute failed: (" . $stmt->errno . ") " . $stmt->error);
}

if (!($res = $stmt->get_result())) {
  die("Getting result set failed: (" . $stmt->errno . ") " . $stmt->error);
}

etc...

It is

$row = $res->fetch_assoc() which causes the ERROR 500.

If I delete it I get

Execute failed: (2031) No data supplied for parameters in prepared statement

What does this mean? Sorry, but I’m really not very good with PHP :thinking:

I found the error.

It was the bind_param. The correct statement is

$stmt->bind_param('s', $_POST["dokid"]);

This is my final code:

<?php
$conn = new mysqli("******.de", "********", "*******", "********");
if ($conn->connect_errno) {
    echo "Failed to connect to MySQL: (" . $conn->connect_errno . ") " . $conn->connect_error;
}

$stmt = $conn->prepare("SELECT DateiPfad, Dateiname FROM ******** WHERE DokumentId = ?");
$stmt->bind_param('s', $_POST["dokid"]);
$stmt->execute();

$res = $stmt->get_result();
$row = $res->fetch_assoc();

$stmt->close();

$file = ('/usr/home/vewade' . $row['DateiPfad'] . '/' . $row['Dateiname']);

if (file_exists($file)) {
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="'.basename($file).'"');
    header('Expires: 0');
    header('Cache-Control: must-revalidate');
    header('Pragma: public');
    header('Content-Length: ' . filesize($file));
    readfile($file);
  } else {
    header("HTTP/1.0 404 Not Found");
}
?>

@George & @patrick,

What do you think? Is it secure enough now?

Thanks a lot for your help with this!

1 Like

Hi @patrick, @George & @Teodor,

is there any chance that this feature request will be integrated in Wappler?

Thanks!

1 Like

I have a similar need but am happy for files to be within the site root but the folder must not be browsable. I need to have files in there which are only accessed via logging into the site.

Can this be achieved just by adding a .htaccess into that folder? If so, how should I structure it?

To summarise…

/files/docs
This contains files for logged in users only

Browsing to mydomain.com/files/docs must never show the contents even if they know the filename like mydomain.com/files/docs/doc1.docx

But a logged in person needs to be able to access the files which would be handled by Wappler-built pages.

Hope that makes sense.

A solution has been posted in this thread:

1 Like