diff --git a/README.md b/README.md index 3c6ca84..df90657 100644 --- a/README.md +++ b/README.md @@ -19,24 +19,26 @@ An HTTPS man-in-the-middle proxy framework written in C#. - OCSP query support (No OCSP stapling). - Certificate Revocation List (CRL) support. - Logging of incoming HTTP requests and responses using different verbosity levels. +- Optional proxy authentication - Web interfaces with HTTP basic authentication support. - Customized web interfaces for remote plugin configuration. - Web interface to view and delete logs remotely. +- IP bans for clients sending too many incorrect login attempts. ## 2. Files The program creates the following folders and files: - - **logs**: Folder that contains one log file for each host, named `.log`, containing + - `logs`: Folder that contains one log file for each host, named `.log`, containing each request sent and response received from that host. - - **-RootCA-.pfx**: PKCS12 file containing the Root CA certificate that will be used to sign - certificates generated by Tiriryarai. The Root CA certificate needs to be - installed in your client, refer to [6. How To Use](#6-how-to-use) for details. - - **-OcspCA-.pfx**: PKCS12 file containing the OCSP CA certificate that will be used to sign - OCSP responses generated by Tiriryarai. The password to this file is `secret`. - - **tiriryarai.pfx**: PKCS12 file containing the certificate that will be used to authenticate the - Tiriryarai host itself. - - **(plugin-hostname).pfx**: PKCS12 file containing the certificate that will be used to authenticate the - Man-in-the-middle plugin. It will use the hostname that was supplied via the - `-d` flag or the local IP address. + - `-RootCA-.pfx`: PKCS12 file containing the Root CA certificate that will be used to sign + certificates generated by Tiriryarai. The Root CA certificate needs to be + installed in your client, refer to [6. How To Use](#6-how-to-use) for details. + - `-OcspCA-.pfx`: PKCS12 file containing the OCSP CA certificate that will be used to sign + OCSP responses generated by Tiriryarai. + - `tiriryarai.pfx`: PKCS12 file containing the certificate that will be used to authenticate the + Tiriryarai host itself. + - `.pfx`: PKCS12 file containing the certificate that will be used to authenticate the + Man-in-the-middle plugin. It will use the hostname that was supplied via the + `-d` flag or the local IP address. The password to all `.pfx` files is `secret`. By default, they can be found in the application data folder, which is `$HOME/.config/Tiriryarai` @@ -45,16 +47,16 @@ on Unix systems. ## 3. Web Interface The following Uri endpoints are used by Tiriryarai and will be invoked if it receives an HTTP request to `http[s]://tiriryarai` as the destination host. - - **/**: Contains a welcome page with instructions and links. - - **/favicon.ico**: Contains the favicon used by the interface. - - **/cert** and **/Tiriryarai.crt**: Contains the Root CA certificate used to sign certificates - generated by Tiriryarai. - - **/ocsp**: Contains an OCSP responder that will send an OCSP response indicating that the certificate in the - request was valid. - - **/revoked.crl**: Contains an empty certificate revocation list, meaning that no certificates have been revoked. - - **/logs/***: Contains a log management interface that lists links to all logs. This endpoint can only be accessed - securely using HTTPS. It is disabled by default and can be enabled using the `-l` flag. If Tiriryarai - was configured to use a username and password, it will be protected using HTTP basic authentication. + - `/`: Contains a welcome page with instructions and links. + - `/favicon.ico`: Contains the favicon used by the interface. + - `/cert` and `/Tiriryarai.crt`: Contains the Root CA certificate used to sign certificates + generated by Tiriryarai. + - `/ocsp`: Contains an OCSP responder that will send an OCSP response indicating that the certificate in the + request was valid. + - `/revoked.crl`: Contains an empty certificate revocation list, meaning that no certificates have been revoked. + - `/logs/*`: Contains a log management interface that lists links to all logs. This endpoint can only be accessed + securely using HTTPS. It is disabled by default and can be enabled using the `-l` flag. If Tiriryarai + was configured to use a username and password, it will be protected using HTTP basic authentication. Tiriryarai supports custom web interfaces for each plugin using the `HomePage()` method in the `IManInTheMiddle` interface, see [5. Adding Plugins](#5-adding-plugins) for details about how to add plugins. The custom web page is diff --git a/Tiriryarai/Http/HttpMessage.cs b/Tiriryarai/Http/HttpMessage.cs index 8048017..9793b1c 100644 --- a/Tiriryarai/Http/HttpMessage.cs +++ b/Tiriryarai/Http/HttpMessage.cs @@ -397,16 +397,17 @@ public void SetDecodedBodyAndLength(byte[] body) } /// - /// Checks if the message has HTTP basic authentication matching the - /// given username and password. Assumes valid username and password. + /// Checks if the message has HTTP basic authentication in the given header + /// matching the given username and password. Assumes valid username and password. /// /// true, if authenticated, false otherwise. + /// The header that should contain the basic authentication. /// The given username. /// The given password. - public bool BasicAuthenticated(string user, string pass) + public bool BasicAuthenticated(string header, string user, string pass) { string[] auth; - string[] authArr = GetHeader("Authorization"); + string[] authArr = GetHeader(header); if (authArr != null && authArr.Length > 0 && authArr[0] != null) { auth = authArr[0].Split(' '); diff --git a/Tiriryarai/Program.cs b/Tiriryarai/Program.cs index 4d74e59..3b725fc 100644 --- a/Tiriryarai/Program.cs +++ b/Tiriryarai/Program.cs @@ -40,9 +40,11 @@ class Program private static uint? MaxLogSize = null; private static string Username = null; private static string Password = null; + private static string ProxyPass = null; private static string ConfigDir = null; private static bool Logs = false; private static bool IgnoreCerts = false; + private static int ReadTimeout = -1; private static bool Help = false; private static bool Version = false; @@ -55,15 +57,21 @@ static void Main(string[] args) List extraOpts = new List(); OptionSet opts = new OptionSet { - { "d|hostname=", "The hostname of the server, if it has one.", (host) => Hostname = host }, + { "d|hostname=", "The hostname of the server, if it has one. If not given, it will default " + + "to the system IP.", (host) => Hostname = host }, { "p|port=", "The port the server will listen on, 8081 by default.", (ushort port) => Port = port }, { "v|verbosity=", "The higher this value is, the more information will be logged.", (uint v) => Verbosity = v }, { "s|logsize=", "The maximum allowed size of a log in MiB before it is deleted.", (uint s) => MaxLogSize = s }, - { "u|username=", "The username required for basic HTTP authentication if one should be required.", (user) => Username = user }, - { "w|password=", "The password required for basic HTTP authentication if one should be required.", (pass) => Password = pass }, + { "u|username=", "The username required for basic HTTP authentication if one should be required. " + + "Used for both proxy authentication and accessing the admin pages.", (user) => Username = user }, + { "w|password=", "The password required for accessing the admin pages if one should be required. " + + "It will be sent securely using HTTPS only.", (pass) => Password = pass }, + { "x|proxypass=", "The password required for using the proxy if one should be required. " + + "It will be sent insecurely using HTTP and should not be the same as the admin password.", (pass) => ProxyPass = pass }, { "c|configdir=", "The directory where certificates, server configuration, and log files will be stored.", (dir) => ConfigDir = dir }, - { "l|logs", "Activate remote log management via the web interface. Usage of authentication recommended.", _ => Logs = true }, + { "l|logs", "Activate admin remote log management via the web interface. Usage of authentication recommended.", _ => Logs = true }, { "i|ignorecerts", "Ignore invalid certificates when sending HTTPS requests.", _ => IgnoreCerts = true }, + { "t|timeout=", "The time in milliseconds to wait on a client request before terminating the connection.", (int t) => ReadTimeout = t }, { "h|help", "Show help", _ => Help = true }, { "version", "Show version and about info", _ => Version = true } }; @@ -116,12 +124,15 @@ where t.GetInterfaces().Contains(typeof(IManInTheMiddle)) ); HttpsMitmProxyParams prms = new HttpsMitmProxyParams(mitms.ElementAt(0), Port, Username, Password) { + ProxyPassword = ProxyPass, ConfigDirectory = ConfigDir, LogVerbosity = Verbosity, MaxLogSize = MaxLogSize, Hostname = Hostname, LogManagement = Logs, - IgnoreCertificates = IgnoreCerts + IgnoreCertificates = IgnoreCerts, + AllowedLoginAttempts = 5, + ReadTimeout = ReadTimeout }; proxy = new HttpsMitmProxy(prms); } diff --git a/Tiriryarai/Server/HttpsMitmProxy.cs b/Tiriryarai/Server/HttpsMitmProxy.cs index 3f2e1cc..3c8e564 100755 --- a/Tiriryarai/Server/HttpsMitmProxy.cs +++ b/Tiriryarai/Server/HttpsMitmProxy.cs @@ -255,33 +255,58 @@ private void ProcessClient(TcpClient client) HttpRequest req; HttpResponse resp; string host; - X509Certificate2 cert; - Stream stream = client.GetStream(); + X509Certificate2 cert = null; + NetworkStream stream = client.GetStream(); + IPAddress clientIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address; try { - try - { - req = HttpRequest.FromStream(stream); - } - catch (Exception e) + if (clientIp == null) + throw new NullReferenceException(nameof(clientIp)); + + if (cache.GetIPStatistics(clientIp).IsBanned(prms.AllowedLoginAttempts)) { - resp = DefaultHttpResponse(400); + resp = DefaultHttpResponse(403); resp.ToStream(stream); - throw e; + client.Close(); + return; } + + if (prms.ReadTimeout > 0) + stream.ReadTimeout = prms.ReadTimeout; try { - host = req.Uri.Split(':')[0]; - cert = cache.GetCertificate(host); + req = HttpRequest.FromStream(stream); } catch (Exception e) { - resp = DefaultHttpResponse(500, req); + resp = DefaultHttpResponse(400); resp.ToStream(stream); throw e; } + if (req.Method == Method.CONNECT) { + host = req.Uri.Split(':')[0]; + if (prms.ProxyAuthenticate && + !IsTiriryarai(host) && + !req.BasicAuthenticated("Proxy-Authorization", prms.Username, prms.ProxyPassword)) + { + // Don't count login attempts here as it would be really easy to get banned by mistake otherwise + resp = DefaultHttpResponse(407, req); + resp.ToStream(stream); + client.Close(); + return; + } + try + { + cert = cache.GetCertificate(host); + } + catch (Exception e) + { + resp = DefaultHttpResponse(500, req); + resp.ToStream(stream); + throw e; + } resp = new HttpResponse(200, null, null, "Connection Established"); resp.ToStream(stream); SslStream sslStream = new SslStream(stream); @@ -291,7 +316,7 @@ private void ProcessClient(TcpClient client) try { req = HttpRequest.FromStream(sslStream); - resp = HandleRequest(req, tls: true); + resp = HandleRequest(req, host, clientIp, tls: true); } catch (Exception e) { @@ -313,7 +338,18 @@ private void ProcessClient(TcpClient client) { // If a non-CONNECT request is received, it will be proxied // directly using the host header. - resp = HandleRequest(req, tls: false); + host = req.Host.Split(':')[0]; + if (prms.ProxyAuthenticate && + !IsTiriryarai(host) && + !req.BasicAuthenticated("Proxy-Authorization", prms.Username, prms.ProxyPassword)) + { + // Don't count login attempts here as it would be really easy to get banned by mistake otherwise + resp = DefaultHttpResponse(407, req); + } + else + { + resp = HandleRequest(req, host, clientIp, tls: false); + } resp.ToStream(stream); } } @@ -327,18 +363,16 @@ private void ProcessClient(TcpClient client) } } - private HttpResponse HandleRequest(HttpRequest req, bool tls) + private HttpResponse HandleRequest(HttpRequest req, string host, IPAddress client, bool tls) { HttpResponse resp; HttpMessage http; try { - string host = req.Host; - Console.WriteLine("\n--------------------\n" + req.Method + (tls ? " https://" : " http://") + host + req.Path); - if (!IsDestinedToMitm(req)) + if (!IsTiriryarai(host)) { if (!prms.MitM.Block(host)) { @@ -355,7 +389,7 @@ private HttpResponse HandleRequest(HttpRequest req, bool tls) } catch (Exception e) { - // TODO Examine exception and return a more descriptive message + // TODO Examine exception and return a more descriptive response logger.LogException(e); return DefaultHttpResponse(502); } @@ -382,7 +416,7 @@ private HttpResponse HandleRequest(HttpRequest req, bool tls) } else { - resp = HomePage(req, tls); + resp = HomePage(req, client, tls); } } catch (Exception e) @@ -393,17 +427,16 @@ private HttpResponse HandleRequest(HttpRequest req, bool tls) return resp; } - private bool IsDestinedToMitm(HttpRequest req) + private bool IsTiriryarai(string host) { // TODO: This may not be an exhaustive list, if there is another // loopback IP, there is a risk of an infinite loop where the proxy // sends requests to itself - string host = req.Host.Split(':')[0]; return host.Equals(Resources.HOSTNAME) || pluginHosts.Contains(host); } - private HttpResponse HomePage(HttpRequest req, bool tls) + private HttpResponse HomePage(HttpRequest req, IPAddress client, bool tls) { HttpResponse resp; string host = req.Host.Split(':')[0]; @@ -415,8 +448,9 @@ private HttpResponse HomePage(HttpRequest req, bool tls) // tiriryarai welcome page with info. resp = DefaultHttpResponse(301, req); } - else if (prms.Authenticate && !req.BasicAuthenticated(prms.Username, prms.Password)) + else if (prms.Authenticate && !req.BasicAuthenticated("Authorization", prms.Username, prms.Password)) { + cache.GetIPStatistics(client).LoginAttempt(); resp = DefaultHttpResponse(401, req); } // From here on, the client is authenticated to access the plugin page @@ -439,8 +473,9 @@ private HttpResponse HomePage(HttpRequest req, bool tls) // default welcome page with info. resp = DefaultHttpResponse(301, req); } - else if (prms.Authenticate && !req.BasicAuthenticated(prms.Username, prms.Password)) + else if (prms.Authenticate && !req.BasicAuthenticated("Authorization", prms.Username, prms.Password)) { + cache.GetIPStatistics(client).LoginAttempt(); resp = DefaultHttpResponse(401, req); } // From here on, the client is authenticated to access configuration pages @@ -480,12 +515,21 @@ private HttpResponse DefaultHttpResponse(int status, HttpRequest req) body = Resources.BAD_PAGE; break; case 401: - resp.SetHeader("WWW-Authenticate", "Basic realm=\"Access to admin pages\""); + resp.SetHeader("WWW-Authenticate", + "Basic realm=\"Access to admin pages. This is sent securely over HTTPS.\""); body = Resources.AUTH_PAGE; break; + case 403: + body = Resources.FORBIDDEN_PAGE; + break; case 404: body = Resources.NON_PAGE; break; + case 407: + resp.SetHeader("Proxy-Authenticate", + "Basic realm=\"Use of the proxy server. This is sent insecurely over HTTP.\""); + body = Resources.PROXY_PAGE; + break; case 500: body = Resources.ERR_PAGE; break; diff --git a/Tiriryarai/Tiriryarai.projitems b/Tiriryarai/Tiriryarai.projitems index a2fea91..4547f76 100644 --- a/Tiriryarai/Tiriryarai.projitems +++ b/Tiriryarai/Tiriryarai.projitems @@ -34,6 +34,7 @@ + diff --git a/Tiriryarai/Util/HttpsMitmProxyCache.cs b/Tiriryarai/Util/HttpsMitmProxyCache.cs index 060eaab..15bd259 100644 --- a/Tiriryarai/Util/HttpsMitmProxyCache.cs +++ b/Tiriryarai/Util/HttpsMitmProxyCache.cs @@ -44,7 +44,7 @@ namespace Tiriryarai.Util { /// - /// A class used for caching certificates, OCSP responses, and CRLs used by + /// A class used for caching certificates, OCSP responses, and other data used by /// man-in-the-middle HTTPS proxies. The content will be cached and removed automatically /// when expired. The cache is self-mantaining in that it shrinks itself if it /// gets too large. The root CA and OCSP CA certificates will be stored on disk instead @@ -221,8 +221,6 @@ public X509OCSPResponse GetOCSPResponse(HttpRequest req) req.Body : Convert.FromBase64String(HttpUtility.UrlDecode(req.SubPath(1))); X509OCSPRequest ocspReq = new X509OCSPRequest(rawOcspReq); - // TODO X509OCSPCertID has no equals method, so is there any way for the cache to tell if an OCSP response - // is already present? return AddOrGetExisting(ocspReq.CertificateID, CreateOCSPResponse, val => ( val as X509OCSPResponse).ExpiryDate ) as X509OCSPResponse; @@ -236,6 +234,17 @@ public X509OCSPResponse GetOCSPResponse(HttpRequest req) ); } + /// + /// Gets the IP client statistics of the given IP. + /// + /// The IP client statistics. + /// The IP whose client statistics to obtain. + public IpClientStats GetIPStatistics(IPAddress ip) + { + return AddOrGetExisting("$" + ip, val => new IpClientStats(), val => DateTime.Now.AddDays(14) + ) as IpClientStats; + } + private static PKCS12 SaveToPKCS12(string path, X509CertificateBuilder cb, AsymmetricAlgorithm key) { PKCS12 p12 = new PKCS12(); diff --git a/Tiriryarai/Util/HttpsMitmProxyParams.cs b/Tiriryarai/Util/HttpsMitmProxyParams.cs index 53f0e18..89296fe 100644 --- a/Tiriryarai/Util/HttpsMitmProxyParams.cs +++ b/Tiriryarai/Util/HttpsMitmProxyParams.cs @@ -30,9 +30,40 @@ class HttpsMitmProxyParams { public IManInTheMiddle MitM { get; } public ushort Port { get; } + + /// + /// Gets the username required for HTTP basic authentication. + /// public string Username { get; private set; } + + /// + /// Gets the password required for HTTP basic authentication. + /// This password is used to access the admin pages and is only sent + /// over HTTPS. + /// public string Password { get; private set; } + private string proxypass; + /// + /// Gets or sets the password required for HTTP basic authentication. + /// This password is for using the proxy server and is sent over + /// plain text HTTP. DO NOT USE THE SAME PASSWORD TO ACCESS THE ADMIN PAGES! + /// + public string ProxyPassword + { + get + { + return proxypass; + } + set + { + if (Username == null && value != null) + throw new ArgumentException("Cannot set proxy password, username must be given."); + + proxypass = value; + } + } + /// /// Gets the HTTPS URL of the MitM proxy. /// @@ -157,7 +188,30 @@ public uint? MaxLogSize /// the custom MitM plugin page and other admin pages. /// /// true if authentication is required; otherwise, false. - public bool Authenticate { get { return Password != null; } } + public bool Authenticate { get { return Username != null; } } + + /// + /// Gets a value indicating whether HTTP basic authentication is required to use + /// the proxy. + /// + /// true if authentication is required; otherwise, false. + public bool ProxyAuthenticate { get { return ProxyPassword != null; } } + + /// + /// Gets or sets a value indicating how many login attempts is allowed from a client + /// before an IP ban. If Authenticate is false, this property is + /// unused. + /// + /// The number of allowed login attempts before an IP ban. Negative values are treated as zero. + public int AllowedLoginAttempts { get; set; } + + /// + /// Gets or sets a value indicating how many milliseconds the proxy will wait for incomming requests + /// from clients. This timeout does not apply for responses from servers. + /// + /// The number of milliseconds to wait for a client request. Negative values and zero are + /// treated as infinite. + public int ReadTimeout { get; set; } private static IPAddress DefaultIPAddress { diff --git a/Tiriryarai/Util/IpClientStats.cs b/Tiriryarai/Util/IpClientStats.cs new file mode 100644 index 0000000..5d6ee11 --- /dev/null +++ b/Tiriryarai/Util/IpClientStats.cs @@ -0,0 +1,49 @@ +// +// Copyright (C) 2021 William Stackenäs +// +// This file is part of Tiriryarai. +// +// Tiriryarai is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Tiriryarai is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +namespace Tiriryarai.Util +{ + /// + /// A class used for remembering behaviour from a specific client IP address + /// + public class IpClientStats + { + private int loginAttempts = 0; + + /// + /// Checks if a client is IP banned, meaning that their login attempts has exceeded + /// the maximum allowed login attempts. + /// + /// true if the client is banned; otherwise false. + /// The maximum allowed login attempts before the + /// client should be considered banned. + public bool IsBanned(int maxLoginAttempts) + { + return loginAttempts >= maxLoginAttempts; + } + + /// + /// Increments the login attempt counter of the IP client + /// + public void LoginAttempt() + { + loginAttempts++; + } + } +} diff --git a/Tiriryarai/Util/Resources.cs b/Tiriryarai/Util/Resources.cs index 174c8db..d4b4550 100644 --- a/Tiriryarai/Util/Resources.cs +++ b/Tiriryarai/Util/Resources.cs @@ -86,22 +86,31 @@ static class Resources public static string BAD_PAGE = string.Format(TEMPLATE_PAGE, "

400 Bad Request

" + - "\"logo\"/" + "

You should check out rfc7231.

" ); public static string AUTH_PAGE = string.Format(TEMPLATE_PAGE, "

401 Unauthorized

" + - "\"logo\"/" + + "\"logo\"/" + "

Please enter your credentials to access the admin pages.

" ); + public static string FORBIDDEN_PAGE = string.Format(TEMPLATE_PAGE, + "

403 Forbidden

" + + "

Nope.

" + ); + public static string NON_PAGE = string.Format(TEMPLATE_PAGE, "

404 Not Found

" + - "\"logo\"/" + + "\"logo\"/" + "

That page was not found.

" ); + public static string PROXY_PAGE = string.Format(TEMPLATE_PAGE, + "

407 Proxy Authentication Required

" + + "

Please enter your credentials to use the proxy.

" + ); + public static string ERR_PAGE = string.Format(TEMPLATE_PAGE, "

500 Internal Server Error

" + "\"logo\"/" +