Secure File Download from a path outside of website root

Hi,

I have to come back to my problem from January, as I’m approaching the end of my conversion of our website from webassist to Wappler.

I can’t figure out how to create the download.php file with Wappler.

This is how my form looks like at the moment:

<form role="form" dmx-bind:id="form1_{{$index}}" method="post" dmx-bind:name="form1_{{$index}}" action="../../../download.php"><input name="hiddenField" type="hidden" id="hiddenField" dmx-bind:value="DokumentId"><button type="submit" class="pull-right btn btn-sm btn-default-transparent btn-animated" name="download" value="Dokument laden">Dokument laden<i class="fa fa-download"></i></button></form>

How should the download.php file look like? I created a action in Sever Connect which filters the query with the hidden POST value to get the correct path and filename.

Thanks for your help in advance.

This is how the download.php file looks at the moment:

<!doctype html>
<html><head>
  <base href="/">
  <meta charset="UTF-8">
  <title>Download</title>
  <script src="dmxAppConnect/dmxAppConnect.js"></script>
  </head>
  <body is="dmx-app" id="download">
    <dmx-serverconnect id="DokumenteDownload" url="dmxConnect/api/Kundenportal/Dokumente/naviDokumenteDownload.php"></dmx-serverconnect>
    <?php 
      header('Content-Type: application/octet-stream');
      readfile("../../usr/home/myuser/private/" . $_POST['dbpath'] . "/" . $_POST['']);
    ?>
  </body></html>

I know, that it is not complete, but I can’t figure out how to insert the path and the filename from my query into the php part.

Hi @George, @Teodor & @patrick,

any idea how to set this up in Wappler?

Hi.
The download.php file should be in the website folder to be accessible from form action.
../../../download.php makes me think, its not. So that’s the first thing you need to fix.

Next, the DokumentId hidden field’s name is hiddenField, so that is what you will need to access the ID.
If that is not the file name, bind file name in the hidden field.
Then, in download.php, you do not have to add app connect or server connects. Just hard code the path, and get the file name from $_POST['hiddenField'] in the readfile method.

Looking at the code, it seems are trying to fetch some document details to make the complete path.
If that is the case, you need to set the form as a Server Connect form and then send the document ID to the server action which would return the extra document details, from DB.
Create another form with action as download.php, method as post, and multiple hidden fields - 1 each for the extra details.
Then in the on-success event of main form server connect, set all the extra details in hidden fields of second form to value from main form server connect response. And, also submit the second form from on-success.

This all seems a bit overkill, but it will keep the path hidden. The file name and other details will still be visible… so if you can fetch it when you get the document ID, you will not need the second form and it will be a bit simpler.

Hi Nishkarsh,

thanks a lot for your time. I think I get the logic now. What I’m missing, is how to get the values for the second form. How do I get the posted values from my first form to insert them in the second form in the same page. Do I have to insert the Server Connect Action which I used in the form, a second time?

The main server connect form which you post documentID with, is the same place you will find the response values.
In the hidden field of second form, go to dynamic attribute > Set Value, then in the dynamic picker, find the main form, and you will see a data option which will contain the response.

A server connection form works exactly like a regular server connect, except that they need a form submit to be invoked.
SCForms also have the same events - executing, processing, start, done, error, success etc.

I succeeded with the correct values for my second form. What doesn’t work is that the second submit does not transfer the POST Values to the download.php file.

This is how my forms look like:

<form role="form" dmx-bind:id="form2_{{$index}}" method="post" dmx-bind:name="form2_{{$index}}" action="../../../dmxConnect/api/Kundenportal/Dokumente/naviDokumenteDownload.php" is="dmx-serverconnect-form" id="NaviAktPersDownload" dmx-on:success="formDownloadPers.submit()"><input name="dokid" type="hidden" id="hiddenFieldPers" dmx-bind:value="DokumentId"><button type="submit" class="pull-right btn btn-sm btn-default-transparent btn-animated" name="download" value="Dokument laden">Dokument laden<i class="fa fa-download"></i></button></form><form id="formDownloadPers" role="form" method="post" action="../../../download.php">
<input id="DateiPfad" name="DateiPfad" type="hidden" dmx-bind:value="NaviAktPersDownload.data.dokumenteDownload[0].DateiPfad"><input id="Dateiname" name="Dateiname" type="hidden" dmx-bind:value="NaviAktPersDownload.data.dokumenteDownload[0].Dateiname">

FYI DateiPfad = Path, Dateiname = Filename

Any idea?

Here is a screenshot of my current download.php

Clever with the 2 forms, but the problem is that App Connect updates the DOM async. The inputs in the second form probably didn’t update before the submit was called.

I will do some testing myself and see if I can find a better solution.

1 Like

Hi @patrick,

thanks a lot for your help.

While Patrick gives a better solution, can you check if the download page is even getting called?
And if it is, what does print_r print?

If that part is working, any only the values are missing… try adding a delay to the success event of first form.
dmx-on:success.debounce:300
This works even on some events which do not expose this option in the UI picker, but I haven’t tested with success event. Here 300 is milliseconds, probably enough time for App Connect to fill in the values. :sweat_smile:

NOTE: This looks like bit of a hack, but delay should work for you in this case as we are calling it only after we have data.
Don’t use it in inconsistent places, where flow of execution might take 2 seconds or 30.

2 Likes

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