Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web UI is available on IP address as domain name, even with Force SSL and correct SSL certificate #839

Closed
sktnetwork opened this issue Dec 23, 2024 · 9 comments

Comments

@sktnetwork
Copy link

Summary

Web UI of the server is still accessible on http. A proper SSL certificate is setup. Web UI works on HTTPS fine. Force redirection from http to https also works, if accessed via domain name.

However, the site is still accessible via IP address, e.g. http://xxx.xxx.xxx.xxx/ and redirects to https at https://xxx.xxx.xxx.xxx/
If allowed in Browser by user, the site then works. This should not happen. The web UI should not work on public IP address.

Possibly, a pixl-server-web module issue.

Why is this important?
Our cloud security audit has failed Cronicle because of this issue, as it's considered as non-secure.

Steps to reproduce the problem

  1. Install cronicle as single server.
  2. Setup https with port 443 and proper letsencrypt based domain certificate.
  3. Assume, server public IP is xxx.xxx.xxx.xxx. Assume domain is example.com
  4. The site works fine on https://example.com/
  5. BUT, also works on https://xxx.xxx.xxx.xxx/
  6. It shows a SSL not trusted error but if ignored, the site still works.
  7. Ideally, it should not, if there's a way to setup the server_name kind of thing, and reject any requests that are not with server_name as domain.

Your Setup

HTTP setup in following fasion in config.json:

"WebServer": {
  "http_port": 80,
  "http_htdocs_dir": "htdocs",
  "http_max_upload_size": 104857600,
  "http_static_ttl": 3600,
  "http_static_index": "index.html",
  "http_server_signature": "Cronicle",
  "http_gzip_text": true,
  "http_timeout": 30,
  "http_regex_json": "(text|javascript|js|json)",
  "http_response_headers": {
	  "Access-Control-Allow-Origin": "*"
  },
  
  "https": true,
  "https_port": 443,
  "https_cert_file": "conf/fullchain.crt",
  "https_key_file": "conf/key.key",
  "https_force": true,
  "https_timeout": 30,
  "https_header_detect": {
	  "Front-End-Https": "^on$",
	  "X-Url-Scheme": "^https$",
	  "X-Forwarded-Protocol": "^https$",
	  "X-Forwarded-Proto": "^https$",
	  "X-Forwarded-Ssl": "^on$"
  }
}

Operating system and version?

Ubuntu 22.04.5 LTS

Node.js version?

v18.17.1

Cronicle software version?

Version 0.9.63

Are you using a multi-server setup, or just a single server?

Single server

Are you using the filesystem as back-end storage, or S3/Couchbase?

Filesystem.

Can you reproduce the crash consistently?

It's not a crash, but yes, consistent issue.

Log Excerpts

@jhuckaby
Copy link
Owner

This is something you need to configure in your cloud or hosting provider. You simply should not be routing port 80 (plain http) traffic to the server if you don't want it. Block it at the firewall level, so port 80 requests never reach the server Cronicle is running on. If you are hosted in AWS, this is called a "Security Group", and each one can only route the ports you want (in this case 443). All cloud and hosting providers allow similar functionality.

You can also do it at the Linux (OS) level using iptables. Instructions are here: https://www.cyberciti.biz/faq/iptables-block-port/

Also, assuming you aren't routing ALL incoming ports to the server (that would be extremely bad), just change the Cronicle http_port configuration property to a non-standard, unrouted port number in the high tens of thousands, e.g. 15492, 16329, etc. This will make it stop listening on port 80 for plain http, and instead listen on another port that is not exposed to the public internet.

Finally, this is more advanced, but you can bind Cronicle's web server to different network interfaces using pixl-server-web, by changing the http_bind_address configuration property in the WebServer object. Set it to 127.0.0.1 to make the port 80 (plain http listener) only listen on the localhost loopback adapter. This will block it from the outside world. Note that you also need to set https_bind_address in this case, to 0.0.0.0 so it listens on all network adapters for port 443 (https) traffic.

However, I highly recommend you block port 80 at the cloud / hosting provider level instead. That's the right way to do it.

@sktnetwork
Copy link
Author

@jhuckaby ,

Thank you very much for quick response and for the suggestions.

However, the problem here's not allowing port 80, but the site still connecting over port 443 via IP address, and of course because the SSL certificate is in the name of the real domain name, the certificate shows error. If the end-user allows this certificate the site still loads.

Please check the following screenshots to clarify what I mean.

Step 1: Load the site in browser: https://xxx.xxx.xxx.xxx/
image

Step 2: Allow unsafe loading, and voila the site still loads:
image

So, e.g. in NginX, an easy way out is SSL reject handshake for the default server_name.
https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_reject_handshake

server {
    listen               443 ssl default_server;
    ssl_reject_handshake on;
}

But, there's no such option in Cronicle / pixl-server-web.

P.S.: The port 80 is blocked from Cloud Firewall, as well as from iptables, but because this is connecting on 443, the port-blocking doesn't work.

@jhuckaby
Copy link
Owner

Oh, I see what you mean now. Let me implement something and I'll get right back to you...

@jhuckaby
Copy link
Owner

jhuckaby commented Dec 23, 2024

Okay, I released pixl-server-web v2.0.4 with a new http_allow_hosts feature, and I just released Cronicle v0.9.64 which now includes pixl-server-web v2.0.4 as a dependency.

To use this feature, first upgrade your Cronicle to v0.9.64, then add a new configuration property in your Cronicle's config.json file, specifically in the WebServer section, named http_allow_hosts. Set it to an array of all the hosts you want to allow on incoming requests. Example:

"http_allow_hosts": ["mydomain.com", "www.mydomain.com"]

This will reject all incoming requests that do not match one or more of the hosts in the http_allow_hosts array. So a request for your IP address directly will be rejected.

See the docs for details on using non-standard port numbers, as you will need to include those in the array.

Hope this helps!

@sktnetwork
Copy link
Author

sktnetwork commented Dec 24, 2024

@jhuckaby,
Thank you very much for such a fast fix / patch.
It works beautifully, but solves half of the problem.

I understand you're in time-crunch and you wish to deep-dive more into it, and maybe in Cronicle v2 / Orchestra, but there's still a kind-of vulnerability on the same topic. Here are the details.

So, someone can still use the site with MITM attack, if he spoofs the "Host" header. e.g., try running this via cURL you can still load the site:
curl -H "Host: example.com" -vvv --insecure https://xxx.xxx.xxx.xxx
Of course, replace the example.com with valid hostname and xxx.xxx.xxx.xxx with the IP address. Doing so, the cURL connects, because pixl-server-web sees valid example.com in http_allow_hosts, and because of --insecure flag the cURL ignores the invalid SSL certificate issued in the name of example.com domain. The output looks like following showing that cURL is able to make insecure SSL requests to the server by ignoring the certificate over port 443:

*   Trying xxx.xxx.xxx.xxx:443...
* Connected to xxx.xxx.xxx.xxx (xxx.xxx.xxx.xxx) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* TLSv1.0 (OUT), TLS header, Certificate Status (22):
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS header, Certificate Status (22):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS header, Finished (20):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.2 (OUT), TLS header, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server accepted to use http/1.1
* Server certificate:
*  subject: CN=example.com
*  start date: Dec 16 08:38:20 2024 GMT
*  expire date: Mar 16 08:38:19 2025 GMT
*  issuer: C=US; O=Let's Encrypt; CN=R10
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
> GET / HTTP/1.1
> Host: example.com
> User-Agent: curl/7.81.0
> Accept: */*
>
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Etag: "259381-3771-1735029373792"
< Last-Modified: Tue, 24 Dec 2024 08:36:13 GMT
< Content-Type: text/html
< Content-Length: 3771
< Cache-Control: public, max-age=3600
< Access-Control-Allow-Origin: *
< Server: Cronicle
< Date: Tue, 24 Dec 2024 08:41:32 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
<!doctype html>
<html lang="en">
<head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
        <title>Loading...</title>
        <meta name="description" content="A simple distributed task scheduler and runner.">
        <meta name="author" content="Joseph Huckaby">
        <link rel="shortcut icon" href="/favicon.ico">

        <link rel="stylesheet" href="css/_combo.css">
</head>

<body>
        <div id="d_message" class="message" style="display:none" onMouseUp="app.hideMessage(250)">
                <div id="d_message_inner" class="message_inner"></div>
        </div>

        <div id="d_scroll_time" style="opacity:0"><i class="fa fa-clock-o">&nbsp;</i><span></span></div>

        <div id="d_header">
                <div class="container">
                        <div id="d_header_logo" class="left">
                                <div class="header_clock_layer" id="d_header_clock_hour"></div>
                                <div class="header_clock_layer" id="d_header_clock_minute"></div>
                                <div class="header_clock_layer" id="d_header_clock_second"></div>
                        </div>
                        <div id="d_header_title" class="left"></div>
                        <div id="d_header_user_container" style="right"></div>
                        <div class="clear"></div>
                </div>
        </div>
        <div class="container">

                <div class="master_content_container">
                        <!-- Main Content Area -->
                        <div class="tab_bar" style="display:none">
                                <div id="tab_Login" class="tab inactive" style="display:none"><span class="content"></span></div>
                                <div id="tab_Home" class="tab inactive"><span class="content"><i class="mdi mdi-home-variant mdi-lg">&nbsp;</i>Home</span></div>
                                <div id="tab_Schedule" class="tab inactive"><span class="content"><i class="mdi mdi-calendar-clock mdi-lg">&nbsp;</i>Schedule</span></div>
                                <div id="tab_History" class="tab inactive"><span class="content"><i class="fa fa-history">&nbsp;</i>Completed</span></div>
                                <div id="tab_JobDetails" class="tab inactive" style="display:none"><span class="content"><i class="fa fa-pie-chart">&nbsp;</i>Job Details</span></div>
                                <div id="tab_MyAccount" class="tab inactive"><span class="content"><i class="mdi mdi-account mdi-lg">&nbsp;</i>My Account</span></div>
                                <div id="tab_Admin" class="tab inactive" style="display:none"><span class="content"><i class="mdi mdi-lock mdi-lg">&nbsp;</i>Admin</span></div>

                                <div id="d_tab_master" class="tab_widget" onMouseUp="app.toggleMasterSwitch()"></div>
                                <div id="d_tab_time" class="tab_widget"><i class="fa fa-clock-o">&nbsp;</i><span></span></div>
                                <div class="clear"></div>
                        </div>

                        <div id="main" class="main">
                                <div id="page_Home" style="display:none"></div>
                                <div id="page_Schedule" style="display:none"></div>
                                <div id="page_History" style="display:none"></div>
                                <div id="page_JobDetails" style="display:none"></div>
                                <div id="page_MyAccount" style="display:none"></div>
                                <div id="page_Admin" style="display:none"></div>
                                <div id="page_Login" style="display:none"></div>
                        </div>

                </div>

                <div id="d_footer">
                        <div class="left">
                                <a href="https://github.com/jhuckaby/Cronicle" target="_blank">Cronicle</a> is
                                &copy; 2015 - 2024 by <a href="http://pixlcore.com" target="_blank">PixlCore</a>.
                                Released under the <a href="https://github.com/jhuckaby/Cronicle/blob/master/LICENSE.md" target="_blank">MIT License</a>.
                        </div>
                        <div id="d_footer_version" class="right">

                        </div>
                        <div class="clear"></div>
                </div>

        </div>

        <script src="js/external/jquery.min.js"></script>
        <script src="js/external/moment.min.js"></script>
        <script src="js/external/moment-timezone-with-data.min.js"></script>
        <script src="js/external/Chart.min.js"></script>
        <script src="js/external/jstz.min.js"></script>

        <script src="js/_combo.js"></script>

        <script src="/socket.io/socket.io.js"></script>
        <script src="/api/app/config"></script>

</body>
* Connection #0 to host xxx.xxx.xxx.xxx left intact

So, the problem is here, that it's still possible to access underlying website by manipulating the host header, leading to an insecure connection, and loading the site. Our cloud security still flags this as a vulnerability that "SSL Certificate Cannot Be Trusted".

There could be many solutions, but based on pixl-server-web code, the easiest one could be, if the RequestURL does not have any of the http_allow_hosts, then:

  1. Rejecting the TLS handshake,
  2. Send HTTP 444 (preferred instead of sending HTTP 403).

In your recent patch, I guess, the easiest would be to check if this.allowHosts along with host of request.url. If both of them are in http_allow_hosts, only then proceed, otherwise throw 444 or 403.

Once again, many thanks for taking this up so quickly.

@jhuckaby
Copy link
Owner

Ah ha, I think what you are talking about is SNI:

Server Name Indication (SNI) is an extension of the TLS protocol that allows the client to indicate the hostname it is connecting to as part of the TLS handshake. In Node.js, you can access this information through the servername property of the TLS connection, which is available on the req.socket object.

I'll work on implementing this as part of the allow hosts thing ASAP.

@jhuckaby jhuckaby reopened this Dec 24, 2024
@jhuckaby
Copy link
Owner

Okay, I think this one should do it! I just released Cronicle v0.9.65 which pulls in pixl-server-web v2.0.5, which now properly implements SNI (TLS handshake) host matching for the http_allow_hosts array.

It should no longer be possible to "trick" the server into receiving an insecure request by forging a fake Host header. Now, the SSL TLS handshake header must match an entry in your http_allow_hosts array.

Let me know if this does the trick!

@sktnetwork
Copy link
Author

@jhuckaby ,
I admire you man to fix it up so quickly. The latest patch v0.9.65 works perfectly this time. The TLS is rejecting the handshake now.
Please consider the issue closed. Lovely and infinite thanks to you.

@jhuckaby
Copy link
Owner

Thank you for helping me increase the security of the app! This was immensely helpful to me. 🙏🏻

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants