├── LICENSE ├── README.md └── htaccess.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 e404 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # .htaccess for nginx 2 | 3 | **.htaccess for nginx** enables the [nginx](https://nginx.org/en/) high performance webserver to deal with `.htaccess` files. 4 | 5 | `.htaccess` files are mainly used for access control and URL rewrite instructions and are widely known across the web community. Originally designed for [Apache](https://www.apache.org/), there is no native implementation available for nginx. While there is a [legitimate reason for this](https://www.nginx.com/resources/wiki/start/topics/examples/likeapache-htaccess/), there would be huge practical benefit if nginx was able to support this. 6 | 7 | **.htaccess for nginx** is efficient and elegant, using micro caching and various performance tweaks right out of the box. It is effortless in its installation and usage. The plugin's deeply integrated approach is ideal for webhosters, who are looking for mixed technology solutions using only nginx and nothing else. 8 | 9 | ## Stop using Apache 10 | 11 | * Apache is slow. 12 | * Apache is wasting resources. 13 | * Compared to nginx, Apache is poorly and inconsistently designed. 14 | * Apache's monolithic design prevents it from scaling properly, while nginx is capable of handling tens of thousands of simultaneous connections with ease. 15 | * Switching to nginx heavily improves performance, efficiency and security. 16 | 17 | ## Reasons for .htaccess in nginx 18 | 19 | When using nginx, there are many **legitimate reasons** to support `.htaccess` files. 20 | 21 | * **Mixed technology.** Imagine using NodeJS and PHP side by side, running on one stable nginx webserver. When dealing with customer webspace, using Apache and nginx together (one proxying the other) is possible, however this adds unnecessary layers of redundancy and heavily wastes valuable server resources. 22 | * **Ease of use.** Everybody knows how to use `.htaccess` files. As of January 2020, [more than 24% of all active websites](https://web.archive.org/web/20200130141042/https://news.netcraft.com/archives/2020/01/21/january-2020-web-server-survey.html) are still run on Apache and thus capable of utilizing `.htaccess` files. If nginx had a way to support this feature, this number would be going down significantly, making the web faster. 23 | * **Legacy.** Just use your old code, without worrying if someone could access a protected directory inside any library you just forgot to handle in your nginx config. 24 | * **Plug and play.** No need to convert `.htaccess` files for nginx and fix all the errors, rant about unsupported or oddly mixed up auto-generated config goo coming from a random online converter. 25 | * **Justified.** Apache performs multiple file reads anyway, so .htaccess for nginx cannot make it worse than Apache, right? In fact, with our built-in micro caching mechanism both, CPU and I/O load are reduced drastically compared to Apache's implementation. 26 | * **For webhosters.** Today, webhosters still need to provide an interface for their customers to change certain aspects of their webserver's behaviour. The decades long and proven `.htaccess` file does just that. 27 | 28 | 29 | ## Performance 30 | 31 | **.htaccess for nginx is incredibly lightweight and fast!** It is written from the ground up with performance optimizations in mind. Even with low-end hardware it adds less than 1 millisecond to your response time, despite supporting quite complex rewrite structures with server variables. 32 | 33 | Physical memory usage of this plugin is insanely low, under 10 KB for each nginx worker process, and it doesn't increase with more requests. 34 | 35 | 36 | ## Requirements 37 | 38 | * Debian or Fedora environment 39 | * `nginx` v1.19+ with Lua module 40 | * `curl` command-line tool 41 | * Optional: `htpasswd` utility (`apache2-utils` package) for `.htpasswd` hashing functions (required for Basic HTTP Authentication) 42 | * Optional: `getent` utility (`libc-bin` package) for hostname lookups (e.g. `Deny from _domainname.tld_`) 43 | 44 | 45 | ## Installation 46 | 47 | 1. Install nginx with the [Lua module](https://github.com/openresty/lua-nginx-module) `libnginx-mod-http-lua` and the `luajit` package. 48 | 1. Debian: `apt-get install nginx libnginx-mod-http-lua luajit` 49 | 2. Fedora: `yum install nginx libnginx-mod-http-lua luajit` 50 | 51 | 1. Verify that the Lua module is properly installed by running: 52 | ```bash 53 | nginx -V 2>&1 | tr ' ' '\n' | grep lua 54 | ``` 55 | It should display something similar to this: 56 | ```bash 57 | --add-module=/lua-nginx-module-a318d250f547c854ea2b091d0e06372ac0c00abc 58 | --add-module=/lua-upstream-nginx-module-0.07 59 | --add-module=/stream-lua-nginx-module-9ce0848cff7c3c5eb0a7d5adfe2de22ea98e1abc 60 | ``` 61 | 2. Build and install the plugin into an appropriate directory accessible by the nginx process, e.g., 62 | ```bash 63 | luajit -b htaccess.lua /etc/nginx/lua/htaccess.lbc 64 | ``` 65 | 3. Add the following configuration to the nginx `http {}` context: 66 | ```nginx 67 | http { 68 | ... 69 | lua_shared_dict htaccess 16m; 70 | ... 71 | } 72 | ``` 73 | This represents a caching system, used on a short-term per-request basis. `.htaccess` lines are usually cached as values for less than 100 milliseconds, but kept in memory as long as there are active connections. You can choose to assign any other memory amount to it, although 16 MB should be more than enough. 74 | 4. Configure the nginx `server {}` context(s) to use the plugin: 75 | ```nginx 76 | server { 77 | ... 78 | rewrite_by_lua_file /path/to/htaccess.lua; 79 | # or reference the bytecode instead 80 | # rewrite_by_lua_file /path/to/htaccess.lbc; 81 | ... 82 | } 83 | ``` 84 | 85 | ## Example 86 | 87 | Create an `.htaccess` file in a directory of your host with the following content: 88 | 89 | ```apache 90 | Order deny,allow 91 | Deny from all 92 | ``` 93 | 94 | When trying to access a file inside this directory through your browser, access should be denied by receiving an `HTTP 403` response. 95 | 96 | 97 | ## Supported Syntax 98 | 99 | The following tables came from [this page](https://htaccess-for-nginx.com/features). 100 | 101 | ### Sections 102 | 103 | | Module | Section | Supported | Notes | 104 | | ------ | ------- | --------- | ----- | 105 | core | `` | No | 106 | core | `` | No | 107 | core | `` | Yes | 108 | core | `` | Yes | 109 | core | `` | No | 110 | core | `` | Never | Impossible to be implemented. Apache specific 111 | core | `` | Yes | 112 | core | `` | Yes | 113 | core | `` | Yes | Emulating supported modules according to supported directives 114 | core | `` | Yes | 115 | core | `` | Yes | 116 | core | `` | Yes | 117 | mod_authz_core | `` | No | 118 | mod_authz_core | `` | No | 119 | mod_authz_core | `` | No | 120 | mod_version | `` | Yes | The version will be simulated as Apache 2.4.0 121 | 122 | ### Directives 123 | 124 | Directives not listed below are not supported. 125 | 126 | | Module | Directive | Supported | Notes | 127 | | ------ | --------- | --------- | ----- | 128 | core | `AcceptPathInfo` | No | 129 | core | `AddDefaultCharset` | No | 130 | core | `CGIMapExtension` | No | 131 | core | `CGIPassAuth` | No | 132 | core | `CGIVar` | No | 133 | core | `ContentDigest` | No | 134 | core | `DefaultType` | No | 135 | core | `EnableMMAP` | No | 136 | core | `EnableSendfile` | No | 137 | core | `ErrorDocument` | No | 138 | core | `FileETag` | No | 139 | core | `ForceType` | No | 140 | core | `LimitRequestBody` | No | 141 | core | `LimitXMLRequestBody` | No | 142 | core | `Options` | No | 143 | core | `QualifyRedirectURL` | No | 144 | core | `RLimitCPU` | Never | Rarely used and not practical for nginx 145 | core | `RLimitMEM` | Never | Rarely used and not practical for nginx 146 | core | `RLimitNPROC` | Never | Rarely used and not practical for nginx 147 | core | `ScriptInterpreterSource` | No | 148 | core | `ServerSignature` | No | 149 | core | `SetHandler` | No | 150 | core | `SetInputFilter` | No | 151 | core | `SetOutputFilter` | No | 152 | mod_access_compat | `Allow` | Yes | `Allow from domainname.tld` requires `getent` command line tool 153 | mod_access_compat | `Deny` | Yes | `Deny from domainname.tld` requires `getent` command line tool 154 | mod_access_compat | `Order` | Yes | 155 | mod_access_compat | `Satisfy` | Never | Security reasons. `Satisfy All` assumed 156 | mod_actions | `Action` | Never | Security reasons. CGI request handling must be in main host config 157 | mod_alias | `Redirect` | Yes | 158 | mod_alias | `RedirectMatch` | Yes | 159 | mod_alias | `RedirectPermanent` | Yes | 160 | mod_alias | `RedirectTemp` | Yes | 161 | mod_auth_basic | `AuthBasicAuthoritative` | No | 162 | mod_auth_basic | `AuthBasicFake` | No | 163 | mod_auth_basic | `AuthBasicProvider` | No | 164 | mod_auth_basic | `AuthBasicUseDigestAlgorithm` | No | 165 | mod_auth_digest | `*` | No | 166 | mod_auth_form | `*` | No | 167 | mod_authn_anon | `*` | No | 168 | mod_authn_core | `AuthName` | Yes | 169 | mod_authn_core | `AuthType` | Partially | Only `AuthType Basic` supported 170 | mod_authn_dbm | `*` | No | 171 | mod_authn_file | `AuthUserFile` | Yes | 172 | mod_authn_socache | `*` | No | 173 | mod_authnz_ldap | `*` | No | 174 | mod_authz_core | `AuthMerging` | No | 175 | mod_authz_core | `Require` | Partially | Require group, host, expr not supported 176 | mod_authz_dbm | `*` | No | 177 | mod_authz_groupfile | `*` | No | 178 | mod_autoindex | `AddAlt` | No | 179 | mod_autoindex | `AddAltByEncoding` | No | 180 | mod_autoindex | `AddAltByType` | No | 181 | mod_autoindex | `AddDescription` | No | 182 | mod_autoindex | `AddIcon` | No | 183 | mod_autoindex | `AddIconByEncoding` | No | 184 | mod_autoindex | `AddIconByType` | No | 185 | mod_autoindex | `DefaultIcon` | No | 186 | mod_autoindex | `HeaderName` | No | 187 | mod_autoindex | `IndexHeadInsert` | No | 188 | mod_autoindex | `IndexIgnore` | No | 189 | mod_autoindex | `IndexIgnoreReset` | No | 190 | mod_autoindex | `IndexOptions` | No | 191 | mod_autoindex | `IndexOrderDefault` | No | 192 | mod_autoindex | `IndexStyleSheet` | No | 193 | mod_autoindex | `ReadmeName` | No | 194 | mod_cern_meta | `*` | No | Rarely used 195 | mod_charset_lite | `CharsetDefault` | No | 196 | mod_charset_lite | `CharsetOptions` | No | 197 | mod_charset_lite | `CharsetSourceEnc` | No | 198 | mod_dir | `DirectoryCheckHandler` | No | 199 | mod_dir | `DirectoryIndex` | No | 200 | mod_dir | `DirectoryIndexRedirect` | No | 201 | mod_dir | `DirectorySlash` | No | 202 | mod_dir | `FallbackResource` | No | 203 | mod_env | `PassEnv` | No | 204 | mod_env | `SetEnv` | No | 205 | mod_env | `UnsetEnv` | No | 206 | mod_expires | `ExpiresActive` | No | 207 | mod_expires | `ExpiresByType` | No | 208 | mod_expires | `ExpiresDefault` | No | 209 | mod_filter | `AddOutputFilterByType` | No | 210 | mod_filter | `FilterChain` | No | 211 | mod_filter | `FilterDeclare` | No | 212 | mod_filter | `FilterProtocol` | No | 213 | mod_filter | `FilterProvider` | No | 214 | mod_headers | `Header` | No | 215 | mod_headers | `RequestHeader` | No | 216 | mod_imagemap | `*` | No | 217 | mod_include | `SSIErrorMsg` | No | 218 | mod_include | `SSITimeFormat` | No | 219 | mod_include | `SSIUndefinedEcho` | No | 220 | mod_include | `XBitHack` | No | 221 | mod_isapi | `*` | No | 222 | mod_ldap | `*` | No | 223 | mod_logio | `*` | No | 224 | mod_lua | `*` | No | 225 | mod_mime | `AddCharset` | No | 226 | mod_mime | `AddEncoding` | No | 227 | mod_mime | `AddHandler` | No | 228 | mod_mime | `AddInputFilter` | No | 229 | mod_mime | `AddLanguage` | No | 230 | mod_mime | `AddOutputFilter` | No | 231 | mod_mime | `AddType` | Yes | 232 | mod_mime | `DefaultLanguage` | No | 233 | mod_mime | `MultiviewsMatch` | No | 234 | mod_mime | `RemoveCharset` | No | 235 | mod_mime | `RemoveEncoding` | No | 236 | mod_mime | `RemoveHandler` | No | 237 | mod_mime | `RemoveInputFilter` | No | 238 | mod_mime | `RemoveLanguage` | No | 239 | mod_mime | `RemoveOutputFilter` | No | 240 | mod_mime | `RemoveType` | No | 241 | mod_negotiation | `ForceLanguagePriority` | No | 242 | mod_negotiation | `LanguagePriority` | No | 243 | mod_reflector | `*` | Never | Security reasons 244 | mod_rewrite | `RewriteBase` | Yes | 245 | mod_rewrite | `RewriteCond` | Partial | Environment (E=) flag is unsupported, as are *CondPattern* integer comparisons and some file attribute tests listed in the [documentation](https://httpd.apache.org/docs/2.4/mod/mod_rewrite.html) 246 | mod_rewrite | `RewriteEngine` | Yes | 247 | mod_rewrite | `RewriteOptions` | No | 248 | mod_rewrite | `RewriteRule` | Yes | 249 | mod_session | `*` | No | 250 | mod_setenvif | `BrowserMatch` | No | 251 | mod_setenvif | `BrowserMatchNoCase` | No | 252 | mod_setenvif | `SetEnvIf` | No | 253 | mod_setenvif | `SetEnvIfExpr` | No | 254 | mod_setenvif | `SetEnvIfNoCase` | No | 255 | mod_speling | `CheckCaseOnly` | No | 256 | mod_speling | `CheckSpelling` | No | 257 | mod_ssl | `SSLCipherSuite` | No | 258 | mod_ssl | `SSLOptions` | No | 259 | mod_ssl | `SSLRenegBufferSize` | No | 260 | mod_ssl | `SSLRequire` | No | 261 | mod_ssl | `SSLRequireSSL` | No | 262 | mod_ssl | `SSLUserName` | No | 263 | mod_ssl | `SSLVerifyClient` | No | 264 | mod_ssl | `SSLVerifyDepth` | No | 265 | mod_substitute | `Substitute` | No | 266 | mod_substitute | `SubstituteInheritBefore` | No | 267 | mod_substitute | `SubstituteMaxLineLength` | No | 268 | mod_usertrack | `CookieDomain` | No | 269 | mod_usertrack | `CookieExpires` | No | 270 | mod_usertrack | `CookieHTTPOnly` | No | 271 | mod_usertrack | `CookieName` | No | 272 | mod_usertrack | `CookieSameSite` | No | 273 | mod_usertrack | `CookieSecure` | No | 274 | mod_usertrack | `CookieStyle` | No | 275 | mod_usertrack | `CookieTracking` | No | 276 | 277 | ### Variables 278 | 279 | Variables not listed below are not supported. 280 | 281 | | Variable | Supported | Notes | 282 | | -------- | --------- | --------- | 283 | `HTTP_*` | Yes | all standard and non-standard HTTP header fields are supported 284 | `HTTPS` | Yes 285 | `DOCUMENT_ROOT` | Yes 286 | `SERVER_ADDR` | Yes 287 | `SERVER_NAME` | Yes 288 | `SERVER_PORT` | Yes 289 | `SERVER_PROTOCOL` | Yes 290 | `REMOTE_ADDR` | Yes 291 | `REMOTE_HOST` | Yes 292 | `REMOTE_USER` | Yes 293 | `REMOTE_PORT` | Yes 294 | `REQUEST_METHOD` | Yes 295 | `REQUEST_FILENAME` | Yes 296 | `REQUEST_URI` | Yes 297 | `QUERY_STRING` | Yes 298 | `SCRIPT_FILENAME` | Yes 299 | `REQUEST_SCHEME` | Yes 300 | `THE_REQUEST` | Yes 301 | `IPV6` | Yes 302 | `TIME` | Yes 303 | `TIME_YEAR` | Yes 304 | `TIME_MON` | Yes 305 | `TIME_DAY` | Yes 306 | `TIME_HOUR` | Yes 307 | `TIME_MIN` | Yes 308 | `TIME_SEC` | Yes 309 | `TIME_WDAY` | Yes 310 | 311 | 312 | ## Tips 313 | 314 | * This plugin tries to make things as secure as possible. **Wherever an unclear situation occurs, access will be denied** to prevent unintended access, e.g. if unsupported, security-critical directives are being used (HTTP 500 response). Unsupported, non-security-related directives will be ignored. 315 | * Global configuration within your `http {}` context is technically possible. However, you are encouraged to use this plugin only in the `server {}` contexts that will need it. 316 | * To make your life easier, you can create a config snippet and include it in the `server {}` config: 317 | ```nginx 318 | server { 319 | ... 320 | include snippets/htaccess.conf 321 | ... 322 | } 323 | ``` 324 | 325 | 326 | ## Debugging Lua inside a Docker container 327 | 328 | Using IntelliJ IDEA, you can remotely debug Lua scripts like `htaccess.lua` running in an nginx Docker container, using [these steps](https://dev.to/omervk/debugging-lua-inside-openresty-inside-docker-with-intellij-idea-2h95). In particular, this has been tested on a Windows 10 host running IntelliJ IDEA 2022.2.4 (Community Edition), with the `fabiocicerchia/nginx-lua:1.23.2-almalinux8.7-20221201` Docker image from https://hub.docker.com/r/fabiocicerchia/nginx-lua. 329 | 330 | It assumes you are mapping a host path of `C:\path\to\project\on\windows` to a path in the container volume of `/path/to/project`. 331 | 332 | 1. Install [IntelliJ IDEA](https://www.jetbrains.com/idea/download/#section=windows) 333 | 1. The container needs to forward port 9966 to allow for Lua debugging. If you are using `docker-compose.yml` it will look something like this: 334 | ```yml 335 | version: '2.4' 336 | services: 337 | nginx-lua: 338 | build: 339 | context: . 340 | container_name: nginx-lua 341 | ports: 342 | # For nginx requests over HTTP 343 | - 80:80 344 | # If you support nginx requests over HTTPS 345 | - 443:443 346 | # For Lua debugging 347 | - 9966:9966 348 | volumes: 349 | - ./relative/path/to/project/on/windows/:/path/to/project/ 350 | ``` 351 | 1. In the `Dockerfile` for the container, run the following command to build the EmmyLuaDebugger from source: 352 | ```bash 353 | RUN dnf install -y cmake && \ 354 | curl https://github.com/EmmyLua/EmmyLuaDebugger/archive/refs/tags/1.0.16.tar.gz \ 355 | -L -o EmmyLuaDebugger-1.0.16.tar.gz && \ 356 | tar -xzvf EmmyLuaDebugger-1.0.16.tar.gz && \ 357 | cd EmmyLuaDebugger-1.0.16 && \ 358 | mkdir -p build && \ 359 | cd build && \ 360 | cmake -DCMAKE_BUILD_TYPE=Release ../ && \ 361 | make install && \ 362 | mkdir -p /usr/local/emmy && \ 363 | cp install/bin/emmy_core.so /usr/local/emmy/ && \ 364 | cd .. && \ 365 | cd .. && \ 366 | rm -rf EmmyLuaDebugger-1.0.16 EmmyLuaDebugger-1.0.16.tar.gz 367 | ``` 368 | 1. Start the container 369 | 1. At the top of your Lua script to debug, add the following: 370 | ```lua 371 | _G.emmy = {} 372 | _G.emmy.fixPath = function(path) 373 | return string.gsub(path, '/path/to/project/', 'C:/path/to/project/on/windows') 374 | end 375 | 376 | package.cpath = package.cpath .. ';/usr/local/emmy/?.so' 377 | local dbg = require('emmy_core') 378 | dbg.tcpListen('localhost', 9966) 379 | dbg.waitIDE() 380 | dbg.breakHere() 381 | ``` 382 | 1. In IDEA, create a Run/Debug configuration per the link and then start the debugger 383 | 384 | When you request a URL that triggers the Lua script, it will pause on the `dbg.breakHere()` line so you can step through the code, watch variables, etc. 385 | -------------------------------------------------------------------------------- /htaccess.lua: -------------------------------------------------------------------------------- 1 | -- htaccess for nginx 2 | -- Version: 1.2.1 3 | -- Copyright (c) 2017-2021 by Gerald Schittenhelm, Roadfamily LLC 4 | -- MIT License 5 | -- Compilation: luajit -b htaccess.lua htaccess-bytecode.lua 6 | 7 | -- TODO: Sometimes code is executed 4 times for each request due to the way nginx handles requests. Make sure it is cached accordingly. 8 | 9 | -- Error function, returns HTTP 500 and logs an error message 10 | local fail = function(msg) 11 | if msg then 12 | ngx.log(ngx.ERR, msg) 13 | end 14 | ngx.exit(500) 15 | end 16 | 17 | -- Halts the script execution 18 | local die = function() 19 | ngx.exit(0) -- os.exit(0) leads to timeouts in nginx 20 | end 21 | 22 | -- Pull some nginx functions to local scope 23 | local decode_base64 = ngx.decode_base64 24 | local unescape_uri = ngx.unescape_uri 25 | 26 | -- Initialize cache 27 | local cache_dict = ngx.shared['htaccess'] 28 | if not cache_dict then 29 | fail('Shared storage DICT "htaccess" not set; define "lua_shared_dict htaccess 16m;" within http configuration block') 30 | end 31 | 32 | -- Calculate a unique request tracing id which remains the same across all subrequests 33 | local trace_id = ngx.var.connection..'.'..ngx.var.connection_requests 34 | 35 | -- Get a cache value 36 | local cache_get = function(key) 37 | return cache_dict:get_stale(key) 38 | end 39 | 40 | -- Set/delete a cache value 41 | local cache_set = function(key, value, expiry_sec) 42 | if not value then 43 | return cache_dict:delete(key) 44 | end 45 | if not expiry_sec or expiry_sec < 0.001 then 46 | expiry_sec = 1 -- expire in 1 second (default) 47 | elseif expiry_sec > 3600 then 48 | expiry_sec = 3600 -- don't allow expire values > 1 hour 49 | end 50 | return cache_dict:set(key, value, expiry_sec) 51 | end 52 | 53 | -- Define request status values 54 | local C_STATUS_SUBREQUEST = 1 55 | local C_STATUS_VOID = 9 56 | 57 | -- Define directory identified placeholder 58 | local C_DIR = '__dir__' 59 | 60 | -- Get request status from shared storage 61 | local request_status = cache_get(trace_id) 62 | 63 | -- Detect void flag (e.g. from RewriteRule flag [END]) 64 | if request_status == C_STATUS_VOID then 65 | die() 66 | end 67 | 68 | -- Determine whether or not this is a subrequest 69 | local is_subrequest 70 | if request_status then 71 | is_subrequest = true 72 | else 73 | is_subrequest = false 74 | cache_set(trace_id, C_STATUS_SUBREQUEST) -- Write subrequest status to cache for any following subrequest 75 | end 76 | 77 | -- The original requested URI including query string 78 | local org_request_uri = ngx.var.request_uri 79 | local org_request_uri_path = org_request_uri:match('^[^%?]+') -- Make sure uri doesn't end on '?', as request_uri will never match that 80 | if org_request_uri:len() > org_request_uri_path:len() then 81 | org_request_uri = unescape_uri(org_request_uri_path)..org_request_uri:sub(org_request_uri_path:len()+1) 82 | else 83 | org_request_uri = unescape_uri(org_request_uri_path) 84 | end 85 | 86 | -- The actual requested URI, not including query string 87 | local request_uri = ngx.var.uri 88 | 89 | -- Backup subrequest detection, in case shared storage failed 90 | if request_uri ~= org_request_uri then 91 | is_subrequest = true 92 | end 93 | 94 | local ip = ngx.var.remote_addr -- The client's real IP address 95 | local rootpath = ngx.var.realpath_root..'/' -- The root directory of the current host 96 | local request_filepath = ngx.var.request_filename -- The requested full file path resolved to the root directory 97 | local request_filename = ngx.var.request_filename:match('/([^/]+)$') -- The requested filename 98 | local request_fileext = ngx.var.request_filename:lower():match('%.([^%./]+)$') -- The requested filename's extension (lower case) 99 | local request_relative_filepath = request_filepath:sub(rootpath:len()) -- the requested relative file path with leading / (might match the request_uri) 100 | 101 | if request_filepath:match('/%.htaccess$') or request_filepath:match('/%.htpasswd$') then 102 | -- Deny access to any .htaccess or .htpasswd file 103 | -- Stick to Apache's default behaviour and return HTTP code 403, even if such a file doesn't exist 104 | ngx.exit(403) 105 | end 106 | 107 | -- Check if file is inside document root 108 | local in_doc_root = function(filepath) 109 | local doc_root = ngx.var.document_root 110 | return (filepath:sub(1, doc_root:len()) == doc_root) 111 | end 112 | 113 | -- Make sure all file operations are contained inside document root, fails when not 114 | local ensure_doc_root = function(filepath) 115 | if not in_doc_root(filepath) then 116 | fail(C_SECURITY_VIOLATION_ERROR..': Trying to read file outside of server root directory ("'..doc_root..'"): "'..filepath..'"') 117 | end 118 | end 119 | 120 | -- Check if a path exists at the file system 121 | -- filepath .... the filename 122 | -- soft_fail ... if true, no fail error will be triggered when not in doc root 123 | local path_exists = function(filepath, soft_fail) 124 | if soft_fail then 125 | if not in_doc_root(filepath) then 126 | return false 127 | end 128 | else 129 | ensure_doc_root(filepath) -- Security: enforce document root 130 | end 131 | local ok, _, code = os.rename(filepath, filepath) 132 | if not ok then 133 | if code == 13 then 134 | return true -- Permission denied, but it exists 135 | end 136 | end 137 | return ok 138 | end 139 | 140 | -- Read contents of any file 141 | local get_file_contents = function(name) 142 | ensure_doc_root(name) -- Security: enforce document root 143 | local file = io.open(name, 'r') 144 | if file == nil then 145 | return nil 146 | end 147 | local content = file:read('*all') 148 | file:close() 149 | return content 150 | end 151 | 152 | -- Check IP address against given IP mask (or a full IP address) 153 | local ip_matches_mask = function(ip, mask) 154 | if ip == mask then 155 | return true 156 | end 157 | if not mask:match('%.$') then 158 | return false 159 | end 160 | if ip:match('^'..mask:gsub('%.', '%.')) then 161 | return true 162 | else 163 | return false 164 | end 165 | end 166 | 167 | -- Check IP address against given host name 168 | local ip_matches_host = function(ip, host) 169 | local hosts_proc = assert(io.popen('getent ahosts '..shell_escape_arg(host)..' | awk \'{ print $1 }\' | sort -u')) -- get all associated IP addresses (IPv4 and IPv6) for host 170 | for res_ip in hosts_proc:lines() do 171 | if ip:match('^'..res_ip..'%s*$') then 172 | return true 173 | end 174 | end 175 | return false 176 | end 177 | 178 | -- Trim string (remove whitespace or other characters at the beginning and the end) 179 | local trim = function(str, what) 180 | if what == nil then 181 | what = '%s' 182 | end 183 | return tostring(str):gsub('^'..what..'+', '', 1):gsub(''..what..'+$', '', 1) 184 | end 185 | 186 | -- shell escape argument 187 | local shell_escape_arg = function(s) 188 | if s:match("[^A-Za-z0-9_/:=-]") then 189 | s = "'"..s:gsub("'", "'\\''").."'" 190 | end 191 | return s 192 | end 193 | 194 | -- Make sure the request_filepath is based on the root path (directory security) 195 | if request_filepath:sub(1, rootpath:len()) ~= rootpath then 196 | die() 197 | end 198 | 199 | -- Try to fetch htaccess lines from cache 200 | local htaccess_cache_key = trace_id..'.h' 201 | local htaccess = cache_get(htaccess_cache_key) 202 | if not htaccess then 203 | -- Walk through the path and try to find .htaccess files 204 | local last_htaccess_dir = rootpath 205 | htaccess = '' 206 | -- Tries to process .htaccess in last_htaccess_dir 207 | -- Soft fails: If there is no .htaccess file, no error will be triggered 208 | local read_htaccess = function() 209 | local filename = last_htaccess_dir..'.htaccess' 210 | local current_htaccess = get_file_contents(filename) 211 | if current_htaccess then 212 | current_htaccess = current_htaccess:gsub('^%s*#[^\n]+\n', '', 1):gsub('\n%s*#[^\n]+', '', 1) -- Strip comments 213 | if current_htaccess:match(C_DIR) then 214 | fail(C_SECURITY_VIOLATION_ERROR) 215 | end 216 | local relative_dir = last_htaccess_dir:sub(rootpath:len()+1) 217 | htaccess = C_DIR..' '..relative_dir..'\n'..htaccess..current_htaccess..'\n' 218 | end 219 | end 220 | read_htaccess() -- process file in root directory first 221 | local next_dir 222 | for part in request_filepath:sub(rootpath:len()+1):gmatch('[^/\\]+') do 223 | -- Walk through directories and try to process .htaccess file 224 | next_dir = last_htaccess_dir..part..'/' 225 | if path_exists(last_htaccess_dir) then 226 | last_htaccess_dir = next_dir 227 | read_htaccess() 228 | else 229 | break 230 | end 231 | end 232 | end 233 | 234 | -- Some constants 235 | local C_VALUE = -11 236 | local C_CTX_INDEX = -12 237 | local C_INDEXED = -21 238 | local C_MULTIPLE = -22 239 | local C_TYPE = -31 240 | local C_ATTR = -32 241 | 242 | -- Initialize global parsed htaccess directives with context flags 243 | -- INDEXED means that instead of having integer based content, each directive type holds a key based map with integer based content tables 244 | -- EXCLUSIVE returns only the last value for a directive within a context stack, which means that this directive cannot hold multiple values 245 | local cdir = { 246 | ['allowfirst'] = {}, 247 | ['deny'] = {}, 248 | ['auth'] = {}, 249 | ['authuserfile'] = {}, 250 | ['authname'] = {}, 251 | ['authcredentials'] = {[C_INDEXED] = true}, 252 | ['errordocs'] = {[C_INDEXED] = true}, 253 | ['rewritebase'] = {}, 254 | ['rewriterules'] = {[C_MULTIPLE] = true}, 255 | ['rewrite'] = {}, 256 | ['contenttypes'] = {[C_INDEXED] = true} 257 | } 258 | 259 | -- Directive context stack, using mapped table assignments to save memory and table copies 260 | local ctx_i = 1 261 | local ctx_map = {{}} 262 | local ctx_used = false 263 | 264 | -- Identifies attributes, even within single or double quotes; returns table of attributes 265 | local parse_attributes = function(input_str) 266 | local working_str = '' 267 | local output = {} 268 | local mode = 0 -- 0 = standard, 1 = single quotes, 2 = double quotes 269 | for i = 1, string.len(input_str), 1 do 270 | local byte = input_str:sub(i,i) 271 | if byte:match('%s') then 272 | if mode == 0 then 273 | if working_str ~= '' then 274 | table.insert(output, working_str) 275 | end 276 | working_str = '' 277 | else 278 | working_str = working_str..byte 279 | end 280 | elseif byte == "'" then 281 | if mode == 0 then 282 | mode = 1 283 | elseif mode == 1 then 284 | table.insert(output, working_str) 285 | working_str = '' 286 | mode = 0 287 | else 288 | working_str = working_str..byte 289 | end 290 | elseif byte == '"' then 291 | if mode == 0 then 292 | mode = 2 293 | elseif mode == 2 then 294 | table.insert(output, working_str) 295 | working_str = '' 296 | mode = 0 297 | else 298 | working_str = working_str..byte 299 | end 300 | else 301 | working_str = working_str..byte 302 | end 303 | end 304 | if working_str ~= '' then 305 | table.insert(output, working_str) 306 | end 307 | return output 308 | end 309 | 310 | -- Add a directive to the global cdir collection 311 | -- directive_type ... e.g. 'rewrite' --or-- {'a', 'b'} for indexed parsed directives, e.g. {'authcredentials', username} 312 | -- value ............ e.g. true 313 | local push_cdir = function(directive_type, value) 314 | ctx_used = true 315 | local value_to_push = { 316 | [C_VALUE] = value, 317 | [C_CTX_INDEX] = ctx_i 318 | } 319 | if type(directive_type)=='table' then 320 | local real_type = directive_type[1] 321 | local index = directive_type[2] 322 | if not cdir[real_type][index] then 323 | cdir[real_type][index] = {} 324 | end 325 | table.insert(cdir[real_type][index], value_to_push) 326 | else 327 | table.insert(cdir[directive_type], value_to_push) 328 | end 329 | end 330 | 331 | -- Helper function to get computed directive value and actual context table 332 | -- local resolve_cdir_value = function(value_table) 333 | -- if not value_table then 334 | -- return nil 335 | -- end 336 | -- return value_table[C_VALUE], ctx_map[value_table[C_CTX_INDEX]] 337 | -- end 338 | 339 | -- Return computed directive by type 340 | -- directive_type ... string of requested type (lowercase), e.g. 'rewriterules' 341 | -- index_or_flag .... index of indexed directive (e.g. 'username') or C_MULTIPLE, for which the entire computed table will be returned 342 | local get_cdir = function(directive_type, index_or_flag) 343 | local has_multiple = (cdir[directive_type][C_MULTIPLE]~=nil) 344 | local is_indexed = (cdir[directive_type][C_INDEXED]~=nil) 345 | local dataset 346 | if index_or_flag and index_or_flag ~= C_MULTIPLE then 347 | if not is_indexed then 348 | fail(C_SECURITY_VIOLATION_ERROR) 349 | end 350 | dataset = cdir[directive_type][index_or_flag] 351 | else 352 | if is_indexed then 353 | fail(C_SECURITY_VIOLATION_ERROR) 354 | end 355 | dataset = cdir[directive_type] 356 | end 357 | if not dataset then 358 | return nil 359 | end 360 | local computed_list = {} 361 | for _, directive in ipairs(dataset) do 362 | table.insert(computed_list, directive[C_VALUE]) 363 | end 364 | if index_or_flag == C_MULTIPLE then 365 | if not has_multiple then 366 | fail(C_SECURITY_VIOLATION_ERROR) 367 | end 368 | return computed_list -- return entire list of values 369 | else 370 | return computed_list[#computed_list] -- return single element (last) 371 | end 372 | end 373 | 374 | -- Add context to current directive context stack 375 | local push_ctx = function(ctx_type, ctx) 376 | local i = ctx_i 377 | if ctx_used then 378 | i = i + 1 379 | if i > 1 then 380 | ctx_map[i] = {} 381 | for _, item in ipairs(ctx_map[i-1]) do 382 | table.insert(ctx_map[i], { 383 | [C_TYPE] = item[C_TYPE], 384 | [C_ATTR] = item[C_ATTR] 385 | }) 386 | end 387 | end 388 | table.insert(ctx_map[i], { 389 | [C_TYPE] = ctx_type, 390 | [C_ATTR] = ctx 391 | }) 392 | ctx_i = i 393 | end 394 | table.insert(ctx_map[i], { 395 | [C_TYPE] = ctx_type, 396 | [C_ATTR] = ctx 397 | }) 398 | ctx_used = false 399 | end 400 | 401 | -- Remove last context from current directive context stack 402 | local pop_ctx = function() 403 | ctx_map[ctx_i+1] = ctx_map[ctx_i-1] 404 | ctx_i = ctx_i + 1 405 | ctx_used = true -- Make sure that if a new context is added right after this call, the stack gets copied and a new index is assigned 406 | end 407 | 408 | -- Remove all contexts; used with new htaccess file 409 | local reset_ctx = function() 410 | local i = ctx_i 411 | ctx_map[i+1] = {} 412 | ctx_i = i + 1 413 | ctx_used = false 414 | end 415 | 416 | -- Parse one line of RewriteRule or RewriteCond 417 | local parse_rewrite_directive = function(params_cs, is_cond) 418 | local result = {} 419 | local i = 1 420 | local stub = false 421 | local quoted = false 422 | for param in params_cs:gmatch('[^%s]+') do 423 | if param:sub(1,1) == '"' then 424 | quoted = true 425 | end 426 | if quoted then 427 | if not stub then 428 | stub = param 429 | else 430 | stub = stub..' '..param 431 | end 432 | else 433 | result[i] = param 434 | end 435 | if param:sub(param:len(),param:len()) == '"' then 436 | quoted = false 437 | result[i] = trim(stub, '"') 438 | stub = false 439 | end 440 | if not quoted then 441 | i = i + 1 442 | end 443 | end 444 | if #result < 3 then 445 | result[3] = false 446 | else 447 | -- Flag separation 448 | local flags = trim(result[3], '[%[%]]') 449 | result[3] = {} 450 | for match in flags:gmatch('[^,]+') do 451 | table.insert(result[3], match) 452 | end 453 | end 454 | if not is_cond and result[1]:sub(1,1) == '!' then 455 | result[4] = true -- Invertion flag (RewriteRule) 456 | result[1] = result[1]:sub(2) 457 | elseif is_cond and result[2]:sub(1,1) == '!' then 458 | result[4] = true -- Invertion flag (RewriteCond) 459 | result[2] = result[2]:sub(2) 460 | else 461 | result[4] = false 462 | end 463 | return result 464 | end 465 | 466 | local parsed_rewritebase 467 | local parsed_rewriteconds = {} 468 | 469 | -- Parse and execute one .htaccess directive 470 | local parse_htaccess_directive = function(instruction, params_cs, current_dir) 471 | local params = params_cs:lower() -- case insensitive directive parameters 472 | if instruction == 'allow' then 473 | if params:match('from%s+all') then 474 | push_cdir('deny', false) 475 | else 476 | for mask in params:match('from%s+(.*)'):gmatch('[^%s]+') do 477 | if (mask:match('%a') and ip_matches_host(ip, mask)) or ip_matches_mask(ip, mask) then 478 | push_cdir('deny', false) 479 | elseif not get_cdir('allowfirst') then 480 | push_cdir('deny', true) 481 | end 482 | end 483 | end 484 | elseif instruction == 'deny' then 485 | if params:match('from%s+all') then 486 | push_cdir('deny', true) 487 | else 488 | for mask in params:match('from%s+(.*)'):gmatch('[^%s]+') do 489 | if (mask:match('%a') and ip_matches_host(ip, mask)) or ip_matches_mask(ip, mask) then 490 | push_cdir('deny', true) 491 | elseif get_cdir('allowfirst') then 492 | push_cdir('deny', false) 493 | end 494 | end 495 | end 496 | elseif instruction == 'order' then 497 | if params:match('allow%s*,%s*deny') then 498 | push_cdir('allowfirst', true) 499 | else 500 | push_cdir('allowfirst', false) 501 | end 502 | elseif instruction == 'authuserfile' then 503 | push_cdir('auth', true) 504 | htpasswd = get_file_contents(params_cs) -- this also checks if file is within root directory; fails on error 505 | push_cdir('authuserfile', params_cs) 506 | if not htpasswd then 507 | fail('AuthUserFile "'..params_cs..'" not found') 508 | end 509 | for line in htpasswd:gmatch('[^\r\n]+') do 510 | line = trim(line) 511 | username, password = line:match('([^:]*):(.*)') 512 | if username then 513 | push_cdir({'authcredentials', username}, password) 514 | end 515 | end 516 | elseif instruction == 'authname' then 517 | push_cdir('auth', true) 518 | push_cdir('authname', params_cs) 519 | elseif instruction == 'authtype' then 520 | push_cdir('auth', true) 521 | if not params == 'basic' then 522 | fail('HTTP Authentication only implemented with AuthType "Basic", requesting "'..params_cs..'"') 523 | end 524 | elseif instruction == 'require' then 525 | if params:match('^all%s+granted') then 526 | push_cdir('deny', false) 527 | elseif params:match('^all%s+denied') then 528 | push_cdir('deny', true) 529 | elseif params:match('^valid.+user') then 530 | -- HTTP Basic Authentication 531 | push_cdir('auth', true) 532 | local auth_success = false 533 | if ngx.var['http_authorization'] then 534 | local type, credentials = ngx.var['http_authorization']:match('([^%s]+)%s+(.*)') 535 | if type:lower() == 'basic' then 536 | credentials = decode_base64(credentials) 537 | local username, password = credentials:match('([^:]+):(.*)') 538 | local parsed_passwd = get_cdir('authcredentials', username) 539 | if username and password and parsed_passwd then 540 | if parsed_passwd == password then 541 | -- Plain text password 542 | auth_success = true 543 | else 544 | -- Hashed password; use htpasswd command line tool to verify 545 | -- xxx 546 | local htpasswd_proc = assert(io.popen('htpasswd -bv '..shell_escape_arg(get_cdir('authuserfile'))..' '..shell_escape_arg(username)..' '..shell_escape_arg(password)..' 2>&1')) 547 | for line in htpasswd_proc:lines() do 548 | if line:match('^Password for user .* correct.%s*$') then 549 | auth_success = true 550 | end 551 | end 552 | end 553 | end 554 | end 555 | end 556 | if auth_success == false then 557 | ngx.header['WWW-Authenticate'] = 'Basic realm='..get_cdir('authname') 558 | ngx.exit(401) 559 | end 560 | elseif params:match('^group') then 561 | fail('"Require group" is unsupported') -- Deny access to avoid false positives 562 | else 563 | local inverted = false 564 | if params:match('^not%s') then 565 | inverted = true 566 | params = params:gsub('^not%s+', ' ', 1) 567 | end 568 | if params:match('^ip%s') then 569 | for mask in params:match('^ip%s+(.*)'):gmatch('[^%s]+') do 570 | if ip_matches_mask(ip, mask) then 571 | if inverted then 572 | push_cdir('deny', false) 573 | else 574 | push_cdir('deny', true) 575 | end 576 | elseif get_cdir('allowfirst') then 577 | if inverted then 578 | push_cdir('deny', true) 579 | else 580 | push_cdir('deny', false) 581 | end 582 | end 583 | end 584 | elseif params:match('^host%s') then 585 | fail('"Require host" unsupported') -- Hostnames are unsupported. Deny access to avoid false positives 586 | elseif params:match('^expr%s') then 587 | fail('"Require expr" unsupported') -- TODO: Require expr "%{VAR} != 'XYZ'"; check if inverted==true 588 | else 589 | fail('Unrecognized parameters ("Require '..params_cs..'")') 590 | end 591 | end 592 | elseif instruction == 'redirect' or instruction == 'redirectmatch' or instruction == 'redirectpermanent' or instruction == 'redirecttemp' then 593 | local attr = parse_attributes(params_cs) 594 | local status = 302 595 | local source, destination 596 | local regex = false 597 | local three_attrs_possible = true 598 | if instruction == 'redirectpermanent' then 599 | status = 301 600 | three_attrs_possible = false 601 | elseif instruction == 'redirecttemp' then 602 | three_attrs_possible = false 603 | else 604 | if instruction == 'redirectmatch' then 605 | regex = true 606 | end 607 | end 608 | local parse_status = function(status_test) 609 | local test_number = tonumber(possible_status) 610 | if test_number then 611 | return test_number 612 | end 613 | status_test = tostring(status_test) 614 | if status_test:match('^[0-9]+$') then 615 | return tonumber(status_test) 616 | elseif status_test == 'permanent' then 617 | return 301 618 | elseif status_test == 'temp' then 619 | return 302 620 | elseif status_test == 'seeother' then 621 | return 303 622 | elseif status_test == 'gone' then 623 | return 410 624 | end 625 | return nil 626 | end 627 | local first_status = parse_status(attr[1]) 628 | if three_attrs_possible and attr[3] then 629 | status = first_status 630 | source = attr[2] 631 | destination = attr[3] 632 | elseif attr[2] then 633 | if three_attrs_possible and first_status then 634 | status = first_status 635 | if status<300 or status>399 then 636 | ngx.exit(status) 637 | end 638 | destination = attr[2] 639 | else 640 | source = attr[1] 641 | destination = attr[2] 642 | end 643 | elseif first_status then 644 | ngx.exit(first_status) 645 | else 646 | destination = attr[1] 647 | end 648 | if destination then 649 | local redirect = true 650 | if source then 651 | redirect = false 652 | if regex then 653 | if ngx.re.match(request_uri, source) then 654 | redirect = true 655 | end 656 | elseif request_uri == source then 657 | redirect = true 658 | end 659 | end 660 | if redirect then 661 | ngx.redirect(destination, status) 662 | end 663 | end 664 | elseif instruction == 'errordocument' then 665 | status, message = params_cs:match('^([0-9]+)%s+(.*)') 666 | if message ~= nil then 667 | push_cdir({'errordocs', tonumber(status)}, message) 668 | end 669 | elseif instruction == 'addtype' then 670 | local attr = parse_attributes(params) 671 | if attr[1] and attr[2] then 672 | local contenttype = attr[1] 673 | i = 2 674 | while attr[i] do 675 | local ext = attr[i] 676 | if ext:sub(1,1) == '.' then 677 | ext = ext:sub(2) 678 | end 679 | push_cdir({'contenttypes', attr[i]}, attr[1]) 680 | i = i + 1 681 | end 682 | end 683 | elseif instruction == 'rewriteengine' then 684 | if params == 'on' then 685 | push_cdir('rewrite', true) 686 | else 687 | push_cdir('rewrite', false) 688 | end 689 | elseif instruction == 'rewritebase' then 690 | parsed_rewritebase = trim(params_cs, '/')..'/' 691 | if parsed_rewritebase == '/' then 692 | parsed_rewritebase = nil 693 | end 694 | elseif instruction == 'rewritecond' then 695 | local rewrite_parsed = parse_rewrite_directive(params_cs, true) 696 | if rewrite_parsed then 697 | table.insert(parsed_rewriteconds, rewrite_parsed) 698 | end 699 | elseif instruction == 'rewriterule' then 700 | local rewrite_parsed = parse_rewrite_directive(params_cs, false) 701 | if rewrite_parsed then 702 | push_cdir('rewriterules', {current_dir, parsed_rewritebase, parsed_rewriteconds, rewrite_parsed}) 703 | end 704 | parsed_rewriteconds = {} -- Reset for next occurence of RewriteCond/RewriteRule; RewriteCond scope is only next RewriteRule 705 | elseif instruction == 'rewritemap' then 706 | fail('RewriteMap is currently unsupported') 707 | -- TODO 708 | elseif instruction == 'rewriteoptions' then 709 | fail('RewriteOptions is not yet implemented') 710 | -- TODO 711 | elseif instruction == 'acceptpathinfo' then 712 | if params == 'on' then 713 | -- TODO 714 | elseif params == 'off' and request_uri ~= request_relative_filepath then 715 | ngx.exit(404) 716 | end 717 | end 718 | end 719 | 720 | -- Replace server variables with their content 721 | local replace_server_vars = function(str, track_used_headers) 722 | local result = str 723 | local svar, replace, first_five 724 | local used_headers = {} 725 | local whitelist = { 726 | ['document_root'] = true, -- %{DOCUMENT_ROOT} 727 | ['server_addr'] = true, -- %{SERVER_ADDR} 728 | ['server_name'] = true, -- %{SERVER_NAME} 729 | ['server_port'] = true, -- %{SERVER_PORT} 730 | ['server_protocol'] = true, -- %{SERVER_PROTOCOL} 731 | ['https'] = true, -- %{HTTPS} 732 | ['remote_addr'] = true, -- %{REMOTE_ADDR} 733 | ['remote_host'] = true, -- %{REMOTE_HOST} 734 | ['remote_user'] = true, -- %{REMOTE_USER} 735 | ['remote_port'] = true, -- %{REMOTE_PORT} 736 | ['request_method'] = true, -- %{REQUEST_METHOD} 737 | ['request_filename'] = true, -- %{REQUEST_FILENAME} 738 | ['query_string'] = true -- %{QUERY_STRING} 739 | } 740 | for org_svar in str:gmatch('%%{([^}]+)}') do 741 | svar = org_svar:lower() -- Make it lowercase, which is nginx convention 742 | first_five = svar:sub(1,5):lower() 743 | replace = '' -- If variable is not found, use an empty string 744 | if first_five == 'http_' then -- %{HTTC_*}, e.g. %{HTTC_HOST} 745 | replace = ngx.var[svar] or '' 746 | if track_used_headers then 747 | table.insert(used_headers, (svar:sub(6):gsub('_', '-'):lower())) 748 | end 749 | elseif first_five == 'http:' then -- %{HTTP:*}, e.g. %{HTTP:Content-Type} 750 | svar = svar:sub(6):gsub('-','_'):lower() 751 | replace = ngx.var['http_'..svar] or '' 752 | if track_used_headers then 753 | table.insert(used_headers, (svar:gsub('_', '-'))) 754 | end 755 | elseif first_five == 'time_' then -- %{TIME_*}, e.g. %{TIME_YEAR} 756 | svar = svar:sub(6) 757 | if svar == 'year' then 758 | replace = os.date('%Y') 759 | elseif svar == 'mon' then 760 | replace = os.date('%m') 761 | elseif svar == 'day' then 762 | replace = os.date('%d') 763 | elseif svar == 'hour' then 764 | replace = os.date('%H') 765 | elseif svar == 'min' then 766 | replace = os.date('%M') 767 | elseif svar == 'sec' then 768 | replace = os.date('%S') 769 | elseif svar == 'wday' then 770 | replace = os.date('%w') 771 | end 772 | elseif whitelist[svar] then 773 | replace = ngx.var[svar] 774 | elseif svar == 'request_uri' then -- %{REQUEST_URI} 775 | -- Use ngx.var['uri'] to match the Apache convention since it doesn't contain the query string 776 | replace = ngx.var['uri'] 777 | elseif svar == 'script_filename' then -- %{SCRIPT_FILENAME} 778 | replace = ngx.var['fastcgi_script_name'] 779 | if not replace or replace == '' then 780 | replace = ngx.var['request_filename'] 781 | else 782 | replace = (ngx.var['document_root']..'/'..script_filename):gsub('/+', '/') 783 | end 784 | replace = script_filename 785 | elseif svar == 'request_scheme' then -- %{REQUEST_SCHEME} 786 | replace = ngx.var['scheme'] 787 | elseif svar == 'the_request' then -- %{THE_REQUEST} 788 | replace = ngx.var['request'] 789 | elseif svar == 'ipv6' then -- %{IPV6} 790 | if not ngx.var['remote_addr']:match('^[0-9]+%.[0-9]+%.[0-9]+%.[0-9]+$') then 791 | replace = 'on' 792 | end 793 | elseif svar == 'time' then -- %{TIME} 794 | replace = os.date('%Y%m%d%H%M%S') 795 | end 796 | result = result:gsub('%%{'..org_svar..'}', replace)..'' 797 | end 798 | if track_used_headers then 799 | return result, used_headers 800 | else 801 | return result 802 | end 803 | end 804 | 805 | -- Walk through all htaccess statements collected from all directories 806 | local block_stack = {} 807 | local block_level = 0 808 | local block_ignore_mode = false 809 | local block_ignore_until = 0 810 | local tag_name, the_rest, last_tag 811 | local current_dir 812 | local stat_instructions_used = {} 813 | local stat_blocks_used = {} 814 | for statement in htaccess:gmatch('[^\r\n]+') do 815 | -- Trim leading whitespace 816 | statement = statement:gsub("^%s*", ""); 817 | 818 | if statement:sub(1,1) == '#' then 819 | -- Comment, so ignore it 820 | elseif statement:sub(1,1) == '<' then 821 | -- handle blocks 822 | if statement:sub(2,2) ~= '/' then 823 | -- opening tag <...> 824 | tag_name = statement:match('^<([^%s>]+)'):lower() 825 | local attr = parse_attributes(statement:sub(string.len(tag_name)+2, string.len(statement)-1)) 826 | local use_block = false 827 | if not block_ignore_mode then 828 | local inverted = false 829 | stat_blocks_used[tag_name] = true 830 | if tag_name == 'ifmodule' then 831 | local module = attr[1]:lower() 832 | if module:sub(1,1) == '!' then 833 | inverted = true 834 | module = module:sub(2) 835 | end 836 | local supported_modules = { 837 | ['rewrite'] = true, 838 | ['alias'] = true, 839 | ['mime'] = true, 840 | ['core'] = true, 841 | ['authn_core'] = true, 842 | ['authn_file'] = true, 843 | ['authz_core'] = true, 844 | ['access_compat'] = true, 845 | ['version'] = true 846 | } 847 | module = module:gsub('^mod_', ''):gsub('_module$', ''):gsub('%.c$', '') 848 | if supported_modules[module] then 849 | use_block = true 850 | else 851 | use_block = false 852 | end 853 | elseif tag_name == 'ifdirective' then 854 | local directive = attr[1]:lower() 855 | if directive:sub(1,1) == '!' then 856 | inverted = true 857 | directive = directive:sub(2) 858 | end 859 | if directive and stat_instructions_used[directive] then 860 | use_block = true 861 | else 862 | use_block = false 863 | end 864 | elseif tag_name == 'ifsection' then 865 | local block = attr[1]:lower() 866 | if block:sub(1,1) == '!' then 867 | inverted = true 868 | block = block:sub(2) 869 | end 870 | if block and stat_blocks_used[block] then 871 | use_block = true 872 | else 873 | use_block = false 874 | end 875 | elseif tag_name == 'iffile' then 876 | local file = attr[1] 877 | if file:sub(1,1) == '!' then 878 | inverted = true 879 | file = file:sub(2) 880 | end 881 | if path_exists(file, true) then 882 | use_block = true 883 | else 884 | use_block = false 885 | end 886 | elseif tag_name == 'files' or tag_name == 'filesmatch' then 887 | use_block = false 888 | local regex = false 889 | local test = attr[1] 890 | if tag_name == 'filesmatch' then 891 | regex = true 892 | elseif attr[1] == '~' then 893 | regex = true 894 | test = attr[2] 895 | end 896 | if regex then 897 | if ngx.re.match(request_filename, test) then 898 | use_block = true 899 | -- TODO: Add match as environment variable 900 | -- [^/]+)"> ==> %{env:MATCH_SITENAME} 901 | end 902 | elseif request_filename == test or request_filename:match(test:gsub('%.', '%.'):gsub('%?', '.'):gsub('*', '.+')) then 903 | use_block = true 904 | end 905 | elseif tag_name == 'limit' or tag_name == 'limitexcept' then 906 | if tag_name == 'limitexcept' then 907 | inverted = true 908 | end 909 | local method = ngx.var['request_method'] 910 | local matches = false 911 | for _, limit in ipairs(attr) do 912 | if limit == method then 913 | matches = true 914 | break 915 | end 916 | end 917 | use_block = matches 918 | elseif tag_name == 'ifversion' then 919 | local simulated_version = '2.4.0' -- Assume Apache version 920 | local cmp = '=' 921 | local test = attr[1] 922 | if attr[2] then 923 | cmp = attr[1] 924 | test = attr[2] 925 | if cmp:sub(1,1) == '!' then 926 | inverted = true 927 | cmp = cmp:sub(2) 928 | end 929 | end 930 | local regex = false 931 | if test:match('^/') and test:match('/$') then 932 | regex = true 933 | test = test:sub(2):sub() 934 | elseif cmp == '~' then 935 | regex = true 936 | end 937 | if regex then 938 | use_block = ngx.re.match(simulated_version, test) 939 | else 940 | local convert_version = function(version) -- calculate a single number out of version string 941 | version = tostring(version):gmatch('[0-9]+') 942 | local i = 0 943 | local total_version = 0 944 | for num in version do 945 | i = i + 1 946 | total_version = total_version + tonumber(num) * 1000000000 * (10 ^ -(i * 3)) 947 | end 948 | return total_version 949 | end 950 | local my_version = convert_version(simulated_version) 951 | local test_version = convert_version(test) 952 | if cmp == '=' or cmp == '==' then 953 | use_block = (my_version == test_version) 954 | elseif cmp == '>' then 955 | use_block = (my_version > test_version) 956 | elseif cmp == '>=' then 957 | use_block = (my_version >= test_version) 958 | elseif cmp == '<' then 959 | use_block = (my_version < test_version) 960 | elseif cmp == '<=' then 961 | use_block = (my_version <= test_version) 962 | end 963 | end 964 | end 965 | if inverted then 966 | use_block = not use_block 967 | end 968 | end 969 | if use_block then 970 | push_ctx(tag_name, attr) 971 | elseif not block_ignore_mode then 972 | block_ignore_mode = true 973 | block_ignore_until = block_level 974 | end 975 | table.insert(block_stack, tag_name) -- push tag to block stack for tracking opening and closing tags (syntax check) 976 | block_level = block_level + 1 977 | else 978 | -- closing tag 979 | tag_name = statement:match('^%s]+)'):lower() 980 | last_tag = table.remove(block_stack) -- pop last tag from block stack 981 | if last_tag ~= tag_name then 982 | fail('.htaccess syntax error: Closing without opening tag') 983 | end 984 | block_level = block_level - 1 985 | if block_ignore_mode then 986 | if block_level == block_ignore_until then 987 | block_ignore_mode = false 988 | block_ignore_until = 0 989 | end 990 | else 991 | pop_ctx() 992 | end 993 | end 994 | 995 | else 996 | local instruction = statement:match('^[^%s]+') 997 | if instruction then 998 | instruction = instruction:lower() -- directive (lower case) 999 | local params_cs = trim(statement:sub(instruction:len()+1)) -- case sensitive directive parameters 1000 | if instruction == C_DIR then -- virtual directive handing over file path of original .htaccess file 1001 | -- new .htaccess file - reset all block awareness features 1002 | block_stack = {} 1003 | block_level = 0 1004 | block_ignore_mode = false 1005 | block_ignore_until = 0 1006 | reset_ctx() -- start with blank contexts 1007 | push_ctx(C_DIR, params_cs) 1008 | current_dir = params_cs 1009 | elseif not block_ignore_mode then 1010 | stat_instructions_used[instruction] = true 1011 | parse_htaccess_directive(instruction, params_cs, current_dir) 1012 | end 1013 | end 1014 | end 1015 | end 1016 | 1017 | -- Execute parsed instructions 1018 | if get_cdir('deny') then 1019 | ngx.exit(403) 1020 | end 1021 | 1022 | -- Actual rewriting 1023 | local parsed_rewriterules = get_cdir('rewriterules', C_MULTIPLE) 1024 | -- Skip rewrite handling if no rules found 1025 | if get_cdir('rewrite') and #parsed_rewriterules > 0 then 1026 | 1027 | -- Rewrite handling 1028 | local uri = request_uri:sub(2) -- Remove leading '/' to match RewriteRule behaviour within .htaccess files 1029 | local dir, base, relative_uri, conds, regex, dst, flags, inverted, matches, cond_met, cond_test, cond_expr, cond_pattern, cond_flags, cond_inverted, cond_matches, cond_vary_headers, used_headers, flag, flag_value, regex_options 1030 | local redirect = false 1031 | local always_matches = {['^']=true, ['.*']=true, ['^.*']=true, ['^.*$']=true} 1032 | local skip = 0 1033 | for _, ruleset in ipairs(parsed_rewriterules) do 1034 | if skip > 0 then 1035 | skip = skip - 1 1036 | goto next_ruleset 1037 | end 1038 | dir = ruleset[1] 1039 | base = ruleset[2] or dir 1040 | if uri:sub(1, base:len()) ~= base then 1041 | goto next_ruleset 1042 | end 1043 | redirect = false 1044 | relative_uri = uri:sub(base:len()+1) 1045 | conds = ruleset[3] 1046 | regex = ruleset[4][1] 1047 | dst = ruleset[4][2]:gsub('%?$', '', 1) -- Make sure destination doesn't end on '?', as request_uri will never match that 1048 | flags = ruleset[4][3] 1049 | inverted = ruleset[4][4] 1050 | -- RewriteCond handling 1051 | cond_met = true 1052 | cond_vary_headers = {} 1053 | if conds then 1054 | for _, condset in ipairs(conds) do 1055 | cond_test = condset[1] 1056 | if cond_test:lower() == 'expr' then 1057 | cond_expr = true 1058 | else 1059 | cond_expr = false 1060 | cond_test, used_headers = replace_server_vars(cond_test, true) 1061 | if used_headers and #used_headers > 0 then 1062 | for _, h in pairs(used_headers) do 1063 | cond_vary_headers[h] = true 1064 | end 1065 | end 1066 | end 1067 | cond_pattern = condset[2] 1068 | cond_inverted = condset[4] 1069 | cond_flags = {} 1070 | regex_options = '' 1071 | if condset[3] then 1072 | for _, flag in pairs(condset[3]) do 1073 | flag = flag:lower() 1074 | if flag == 'nocase' then -- [NC] 1075 | flag = 'nc' 1076 | elseif flag == 'ornext' then -- [OR] 1077 | flag = 'or' 1078 | elseif flag == 'novary' then -- [NV] 1079 | flag = 'nv' 1080 | end 1081 | cond_flags[flag] = true 1082 | end 1083 | end 1084 | if cond_flags['nc'] then 1085 | regex_options = 'i' 1086 | end 1087 | if cond_expr then -- 'expr' conditions 1088 | fail('RewriteCond expressions ("expr ...") are unsupported') -- We don't support expr style conditions due to their weird complexity and redundancy 1089 | elseif cond_pattern:sub(1,1) == '-' then -- File attribute tests or integer comparisons (case sensitive) 1090 | local filepath = cond_test:gsub('/$','',1) 1091 | if cond_pattern == '-d' then -- is directory 1092 | cond_matches = path_exists(filepath..'/') 1093 | elseif cond_pattern == '-f' or cond_pattern == '-F' then -- is file 1094 | cond_matches = path_exists(filepath) and not path_exists(filepath..'/') 1095 | else 1096 | fail('RewriteCond pattern unsupported: '..cond_pattern) 1097 | end 1098 | elseif cond_pattern:match('^[<>=]') then -- Lexicographical string comparisons 1099 | local comparison_operator = cond_pattern:match('^([=<>]+)'); 1100 | local expression_to_compare = cond_pattern:gsub('^([=<>]+)', ''); 1101 | if (comparison_operator == '=') then 1102 | cond_matches = cond_test == expression_to_compare 1103 | elseif (comparison_operator == '<') then 1104 | cond_matches = cond_test < expression_to_compare 1105 | elseif (comparison_operator == '>') then 1106 | cond_matches = cond_test > expression_to_compare 1107 | elseif (comparison_operator == '<=') then 1108 | cond_matches = cond_test <= expression_to_compare 1109 | elseif (comparison_operator == '>=') then 1110 | cond_matches = cond_test >= expression_to_compare 1111 | else 1112 | fail('RewriteCond lexicographical string pattern unsupported: '..cond_pattern) 1113 | end 1114 | else 1115 | cond_matches = ngx.re.match(cond_test, cond_pattern, regex_options) 1116 | end 1117 | if cond_inverted then 1118 | cond_matches = not cond_matches 1119 | end 1120 | if cond_matches then 1121 | cond_met = true 1122 | if cond_flags['or'] then 1123 | goto handle_conds 1124 | end 1125 | else 1126 | cond_met = false 1127 | if not cond_flags['or'] then 1128 | goto next_ruleset 1129 | end 1130 | end 1131 | -- Add "Vary" header if no [NV] flag is present and headers have been used 1132 | if not cond_flags['nv'] then 1133 | local vary = false 1134 | for h, _ in pairs(cond_vary_headers) do 1135 | h = h:sub(1, 1):upper()..h:sub(2):gsub('-%l', string.upper) -- Uppercase header words 1136 | if vary then 1137 | vary = vary..', '..h 1138 | else 1139 | vary = h 1140 | end 1141 | end 1142 | if vary then 1143 | ngx.header['Vary'] = vary 1144 | end 1145 | end 1146 | end 1147 | end 1148 | ::handle_conds:: 1149 | if not cond_met then 1150 | goto next_ruleset 1151 | end 1152 | -- Flag handling 1153 | regex_options = '' 1154 | local flag_fns = {} -- These functions are being called once rule is matched 1155 | if flags then 1156 | for _, rawflag in pairs(flags) do 1157 | flag = rawflag:match('^[^=]+'):lower() -- flags are case insensitive 1158 | flag_value = '' 1159 | if flag then 1160 | if flag:len() < rawflag:len() then 1161 | flag_value = rawflag:sub(flag:len()+2) 1162 | end 1163 | if flag == 'nc' or flag == 'nocase' then -- [NC] 1164 | regex_options = regex_options..'i' 1165 | elseif flag == 'co' or flag == 'cookie' then -- [CO=NAME:VALUE:DOMAIN:lifetime:path:secure:httponly] 1166 | table.insert(flag_fns, { 1167 | val = flag_value, 1168 | fn = function(val) 1169 | if not val then 1170 | return 1171 | end 1172 | local separator = ':' 1173 | if val:sub(1,1) == ';' then 1174 | separator = ';' 1175 | val = val:sub(2) 1176 | end 1177 | stubs = {} 1178 | for stub in val:gmatch('[^'..separator..']+') do 1179 | table.insert(stubs, stub) 1180 | end 1181 | if not stubs[1] or not stubs[2] then return end 1182 | local cookie = stubs[1]..'='..stubs[2] 1183 | if not stubs[3] then goto set_cookie end 1184 | cookie = cookie..'; Domain='..stubs[3] 1185 | if not stubs[4] then goto set_cookie end 1186 | cookie = cookie..'; Expires='..ngx.cookie_time(ngx.time() + stubs[4]*60) 1187 | if not stubs[5] then goto set_cookie end 1188 | cookie = cookie..'; Path='..stubs[5] 1189 | if not stubs[6] then goto set_cookie end 1190 | if ({['1'] = true, ['secure'] = true, ['true'] = true})[stubs[6]:lower()] then 1191 | cookie = cookie..'; Secure' 1192 | end 1193 | if not stubs[7] then goto set_cookie end 1194 | if ({['1'] = true, ['httponly'] = true, ['true'] = true})[stubs[7]:lower()] then 1195 | cookie = cookie..'; HttpOnly' 1196 | end 1197 | ::set_cookie:: 1198 | ngx.header['Set-Cookie'] = cookie 1199 | end 1200 | }) 1201 | elseif flag == 'l' or flag == 'last' then -- [L] 1202 | -- TODO: Jump to next htaccess in any subdirectory 1203 | table.insert(flag_fns, { 1204 | fn = function() 1205 | cache_set(trace_id, C_STATUS_VOID) -- Mark request as void 1206 | end 1207 | }) 1208 | elseif flag == 'end' then -- [END] 1209 | table.insert(flag_fns, { 1210 | fn = function() 1211 | cache_set(trace_id, C_STATUS_VOID) -- Mark request as void 1212 | end 1213 | }) 1214 | elseif flag == 'bnp' or flag == 'backrefnoplus' then -- [BNP] 1215 | -- Do nothing, we're gonna use '%20' instead of '+' anyway 1216 | elseif flag == 'f' or flag == 'forbidden' then -- [F] 1217 | redirect = 403 1218 | elseif flag == 'g' or flag == 'gone' then -- [F] 1219 | redirect = 410 1220 | elseif flag == 'r' or flag == 'redirect' then -- [R] 1221 | if flag_value:match('^[0-9]+$') then 1222 | redirect = tonumber(flag_value) 1223 | else 1224 | redirect = 302 1225 | end 1226 | elseif flag == 'qsa' or flag == 'qsappend' then -- [QSA] 1227 | local qs = org_request_uri:match('%?.*') 1228 | if qs then 1229 | local new_qs = dst:match('%?.*') 1230 | if new_qs then 1231 | dst = dst:gsub('%?.*', '', 1)..qs..'&'..new_qs:sub(2) 1232 | end 1233 | end 1234 | elseif flag == 'qsd' or flag == 'qsdiscard' then -- [QSD] 1235 | -- No-op, since relative_uri doesn't contain the query string anyway 1236 | elseif flag == 's' or flag == 'skip' then -- [S=n] 1237 | if flag_value:match('^[0-9]+$') then 1238 | skip = flag_value 1239 | else 1240 | fail('Invalid flag value: ['..rawflag..'], expecting a number') 1241 | end 1242 | elseif flag == 'e' then -- [E=] 1243 | -- Trying to set or unset an environment variable 1244 | -- https://httpd.apache.org/docs/2.4/rewrite/flags.html 1245 | fail('RewriteRule flag E is unsupported') 1246 | else 1247 | fail('Unsupported RewriteRule flag: '..flag) 1248 | end 1249 | end 1250 | end 1251 | end 1252 | -- Match handling 1253 | if always_matches[regex] then 1254 | matches = true 1255 | else 1256 | matches = ngx.re.match(relative_uri, regex, regex_options) 1257 | end 1258 | if inverted then 1259 | matches = not matches -- Invert matches 1260 | end 1261 | if matches then 1262 | -- Perform flag operations on match 1263 | for _, flag in pairs(flag_fns) do 1264 | flag['fn'](flag['val']) 1265 | end 1266 | if dst ~= '-' then -- '-' means don't perform a rewrite 1267 | dst = replace_server_vars(dst) -- Apply server variables 1268 | if type(matches) == 'table' then 1269 | -- Replace captured strings from RewriteRule ($n) in dst 1270 | for i, match in ipairs(matches) do 1271 | dst = dst:gsub('%$'..i, match:gsub('%%', '%%%%')..'') -- make sure no capture indexes are being used as replacement 1272 | end 1273 | end 1274 | if type(cond_matches) == 'table' then 1275 | -- Replace captured strings from RewriteCond (%n) in dst 1276 | for i, match in ipairs(cond_matches) do 1277 | dst = dst:gsub('%%'..i, match:gsub('%%', '%%%%')..'') -- make sure no capture indexes are being used as replacement 1278 | end 1279 | end 1280 | if (not redirect or not dst:match('^https?://')) and dst:sub(1,1) ~= '/' then 1281 | dst = '/'..base..dst 1282 | end 1283 | if dst:match('%.%.') then 1284 | fail('Parent directory selector /../ not allowed in RewriteRule for security reasons') 1285 | end 1286 | if request_uri ~= dst then 1287 | if redirect then 1288 | -- Perform an external redirect or final HTTP status 1289 | if redirect > 300 and redirect < 400 then 1290 | ngx.redirect(dst, redirect) 1291 | else 1292 | ngx.exit(redirect) 1293 | end 1294 | else 1295 | -- Perform an internal subrequest 1296 | cache_set(htaccess_cache_key, htaccess, 0.1) -- Cache htaccess lines right before subrequest 1297 | ngx.exec(dst) 1298 | end 1299 | end 1300 | end 1301 | end 1302 | ::next_ruleset:: 1303 | end 1304 | 1305 | end 1306 | 1307 | -- Execute directives other than RewriteRule 1308 | 1309 | -- Add Content-Type header according to AddType 1310 | if request_fileext ~= nil then 1311 | local contenttype = get_cdir('contenttypes', request_fileext) 1312 | if contenttype ~= nil then 1313 | ngx.header['Content-Type'] = contenttype 1314 | end 1315 | end 1316 | 1317 | -- ErrorDocument handling 1318 | if false then -- TODO: Check if file exists or access denied... how to do that? 1319 | local status = 404 1320 | local response = get_cdir('errordocs', status) 1321 | if response then 1322 | if response:sub(1,1) == '/' then 1323 | -- URI request 1324 | ngx.exec(response) 1325 | else 1326 | if response:match('^https?://') and not response:match('%s') then 1327 | -- Internal redirect 1328 | ngx.status = status 1329 | ngx.redirect(response) 1330 | else 1331 | -- String output 1332 | ngx.status = status 1333 | ngx.print(response) 1334 | ngx.exit(status) 1335 | end 1336 | end 1337 | else 1338 | ngx.exit(status) 1339 | end 1340 | end 1341 | --------------------------------------------------------------------------------