Fix path traversal/injection security bug. Thanks to Steven Frank at panic.com for...
[gemini-php] / gemini.class.php
1 <?php
2 error_reporting(E_ALL);
3 set_time_limit(0);
4 ob_implicit_flush();
5
6 class Gemini {
7
8         function __construct($config) {
9                 if(empty($config['certificate_file']))
10                         die("Missing certificate file.  Edit config.php\n");
11                 $this->ip = "0";
12                 $this->port = "1965";
13                 $this->data_dir = "hosts/";
14                 $this->default_host_dir = "default/";
15                 $this->default_index_file = "index.gemini";
16                 $this->logging = 1;
17                 $this->log_file = "logs/gemini-php.log";
18                 $this->log_sep = "\t";
19                 $settings = array('ip', 'port', 'data_dir', 'default_host_dir', 'default_index_file',
20                                 'certificate_file', 'certificate_passphrase');
21                 foreach($settings as $setting_key) {
22                         if(!empty($config[$setting_key]))
23                                 $this->$setting_key = $config[$setting_key];
24                 }
25                 // append the required filepath slashes if they're missing
26                 if(substr($this->data_dir, -1) != "/")
27                         $this->data_dir .= "/";
28                 if(substr($this->default_host_dir, -1) != "/")
29                         $this->default_host_dir .= "/";
30                 if($this->logging) {
31                         if(!file_exists($this->log_file)) {
32                                 $this->log_to_file("Log created", null, null, null, null);
33                         }
34                         if(!is_writable($this->log_file)) {
35                                 die("{$this->log_file} is not writable.\n");
36                         }
37                 }
38                 if(!is_readable($this->certificate_file))
39                         die("Certificate file {$this->certificate_file} not readable.\n");
40         }
41
42         function parse_request($request) {
43                 $url = trim($request); // strip <CR><LF> from the end
44                 return parse_url($url);
45         }
46
47         function get_valid_hosts() {
48                 $dirs = array_map('basename', glob($this->data_dir.'*', GLOB_ONLYDIR));
49                 return $dirs;
50         }
51
52         function get_status_code($filepath) {
53                 if(is_file($filepath) and file_exists($filepath))
54                         return "20";
55                 if(!file_exists($filepath))
56                         return "51";
57                 return "50";
58         }
59
60         function get_mime_type($filepath) {
61                 $type = mime_content_type($filepath);
62                 // we need a way to detect gemini file types, which PHP doesn't
63                 // so.. if it ends with gemini (or if it has no extension), assume
64                 $path_parts = pathinfo($filepath);
65                 if(empty($path_parts['extension']) or $path_parts['extension'] == "gemini")
66                         $type = "text/gemini";
67                 return $type;
68         }
69
70         /**
71         * Gets the full file path (assumes directory structure based on host)
72         *
73         * This function determines where the requested file is stored
74         * based on the hostname supplied in the request from the client.
75         * If no host is supplied, the default directory is assumed.
76         *
77         * @param array $url An array returned by the parse_request() method
78         *
79         * @return string
80         */
81         function get_filepath($url) {
82                 $hostname = "";
83                 if(!is_array($url))
84                         return false;
85                 if(!empty($url['host']))
86                         $hostname = $url['host'];
87
88                 $valid_hosts = $this->get_valid_hosts();
89                 if(!in_array($hostname, $valid_hosts))
90                         $hostname = "default";
91
92                 // Kristall Browser is adding "__" to the end of the filenames
93                 // wtf am I missing?
94                 // also removing ".." to mitigate against directory traversal
95                 $url['path'] = str_replace(array("..", "__"), "", $url['path']);
96                 // force an index file to be appended if a filename is missing
97                 if(empty($url['path'])) {
98                         $url['path'] = "/".$this->default_index_file;
99                 } elseif(substr($url['path'], -1) == "/") {
100                         $url['path'] .= $this->default_index_file;
101                 }
102
103                 $valid_data_dir = dirname(__FILE__)."/".$this->data_dir;
104                 $return_path = $this->data_dir.$hostname.$url['path'];
105                 // check the real path is in the data_dir (path traversal sanity check)
106                 if(substr(realpath($return_path),0, strlen($valid_data_dir)) == $valid_data_dir) {
107                         return $return_path;
108                 }
109                 return false;
110         }
111
112         function log_to_file($ip, $status_code, $meta, $filepath, $filesize) {
113                 $ts = date("Y-m-d H:i:s", strtotime('now'));
114                 $this->log_sep;
115                 $str = $ts.$this->log_sep.$ip.$this->log_sep.$status_code.$this->log_sep.
116                 $meta.$this->log_sep.$filepath.$this->log_sep.$filesize."\n";
117                 file_put_contents($this->log_file, $str, FILE_APPEND);
118         }
119 }
120
121 ?>