(security) Protecting joplin server exposed to the internet against bruteforce logins?

Hey guys, how do you protect the server exposed to the internet against login bruteforce attacks?

  • is there a way to setup fail2ban to catch failed attempts from the log?
  • perhaps there is a way to enable captcha?

can't find anything specific in the documentation
Joplin Server: 2.10.5-beta
Environment: Running in a docker container behind cloudflare proxy

If you can add a layer of cloudflare on the outside, I think this kind of problem can be solved

So far I found also only limited options to put some protective measurements in place:

From my side there are two authentication mechnisms in scope

  1. web-ui login via "APP_BASE_URL/login": here you can enhance the reverse-proxy to e.g. limit it to a specific IP (apache httpd > "Require ip" or even more complex things) or just track failures

  2. the client authentication done via POST /api/sessions
    The app only has very very limited auditing options in the Route Handler logging
    joplin/routeHandler.ts at dev · laurent22/joplin · GitHub
    In case of a failed login of client you will see on the docker logging a message like:
    2023-01-12 09:02:48: [error] App: 403: POST /api/sessions:...IP_ADDR....: Invalid username or password: {"email":"...USER_EMAIL..."}

fail2ban can like track both logs (Reverse poxy weblog & applog on docker container) for failed requests on "/login" "/api/sessions"

In addition to pretiction I'm concerned about auditing capabilities as the AppLog has only a very limited output on route handler on sucessfull req/resp cycles:

tx.joplin.appLogger().info(${ctx.request.method} ${ctx.path} (${ctx.response.status}) (${requestDuration}ms));

From my perspective I would request to get the route handler enhanced including user context & IP (owner.id : userIp(ctx)). If ever a password is leaked > you will never find out at all when/what happend.
I don't know how joplin cloud is setup to to ensure that suspecious behaviour is found.

Also from an APM perspective woulld be interesting to have the user context in the log,

If I would be a node.js developer with a full blow dev setup, I would modify it myself, but I'm a node.js newbie

1 Like

yeah, it appears that currently there are not many "tinker-free" options available to secure the installation. which I find very surprising as the product has been on the market for over 2 years....

thanks for the suggestions.

  1. for fail2ban to work, is there a simple way to output logs to both stdout and standalone files which fail2ban can pickup? also will the log rotation work in this case or will it require more tinkering? Can we set the log levels(INFO, WARN, ERROR, DEBUG)? Any suggestions?

  2. as for limiting the IP ranges to access the web UI at ${APP_BASE_URL}/login. In my opinion, it would be better not to expose the server at all and keep it on the intranet accessible via a VPN only... Both options are not feasible as some people on the team won't/cant use a VPN/static IPs for this purpose.

  3. alternatively, we can use a 3rd party service for syncing notes for one user e.g. Nextcloud which has better security in place. but it defies the whole purpose of hosting your own Joplin server and has certain limitations such as inability to share notes/notebooks with other team members.

Thanks!

hmm. not sure I'm following you... can you please explain how "adding a layer of cloudflare on the outside" can solve the brutefoce attacks to api route and webui?

I use Cloudflare Tunnel (Cloudflare Tunnel · Cloudflare Zero Trust docs) to protect my self hosted Joplin server.

The advantages are that your public ip is not exposed, your Joplin access is protected against DDoS and other attacks by Cloudflare, and you can also limit the access based on custom rules (like authorizing access only from your country and block all others)

Cloudflare already provides a large number of services to solve this kind of problem, there is no need to solve security problems by yourself. In fact, I don't think it's possible for the average technical user to tackle better than it in the cybersecurity space.
For example API Shield: Security · Cloudflare API Shield docs


Note: I am just an developer, if there are any mistakes in the above suggestions, please directly say

There is already a built-in rate limiter on certain actions - login and sessions in particular. This is mostly for security reasons though - to avoid DoS attack, something like Cloudflare is indeed better.

2 Likes

Thanks for the reference and the software.

  • Would it be possible to add the IP address of the client trying to authenticate in the log output? So we can point a fail2ban to pickup this IP and ban on CF edge.
    something like:
		throw new ErrorTooManyRequests(`IP: 123.45.67.89 Too many login attempts. Please try again in ${Math.ceil(result.msBeforeNext / 1000)} seconds.`, result.msBeforeNext);
  • From a security standpoint wouldn't it be better to remove the notification Please try again in ${Math.ceil(result.msBeforeNext / 1000)} seconds.? so the bots can't guess the cool-down period?

  • any particular way to write logs both to STDOUT and error.log in docker container without redirecting log outputs on the docker-daemon level?

I've seen this. Do you have any experience implementing it without modifications to the code base of the project? Can you please share your method?

I am using Zerotrust tunnels myself and quite familiar with this tech. however I do not understand how does it mitigate your Joplin access is protected against DDoS risks ?

Did you check the log entry? The IP is probably added there at some point

Because Cloudflare sits between clients outside your network and your self-hosted server, and it automatically add some kind of network protection (like DDoS) even in the free tier.

Basically, lets say your Joplin URL is https://joplin.mydomain.com. This URL is used by the client for sycning with the server, and by admins to access the management interface.

With Cloudflare Tunnel, joplin.mydomain.com point to a Cloudflare Edge server (instead of YOUR internal network directly), and then traffic is routed to your internal network trough Cloudflare Tunnel.

In my case the Cloudflare Tunnel agent point on an internal NGINX proxy which THEN point to my hosted Joplin. The path is:

Joplin client (from outside, ex. on my mobile phone in 4G) > Cloudflare Edge server > Cloudflare Tunnel agent > NGINX reverse proxy > self-hosted Joplin server.

Despite all this, syncing in Joplin clients is very fast (1-2 sec)

Well... I am running the same double-proxy configuration myself, and CF in this case won't protect you against any malicious actor who has discovered Joplin's API endpoint or web login page and started brute forcing. Definitely not while using the default CF settings on a "free-tier" account.

How did you achieve that in your case?

The idea was to point fail2ban to both logs (web console and api) to catch failed attempts and then ban these IPs via CF WAF automatically using f2b jails. It works well on any other service exposed to the internet (MM, Nextcloud, Jenkins) but I can't find a simple way to get the logs from Joplin while running it in the dockerized environment.

@ laurent I will check the info in the logs tomorrow and will let you know. By chance do you have f2b regexp to catch failed logins? Thanks!

The free tier of CF is ideed very limited regarding rate limiting, but still available in your site > Security > WAF > Rate limiting rules and adding a rule for "URI PATH = /login".

I tested it manually by trying to login multiple times from my browser and also from a python script, and both time, CF blocked me after a few attempts.

It's better than nothing, but you're right by saying that implementing fail2ban woud be a better solution

1 Like

I don't see any logs in stdout about failed attempts. Just tried to login via ${APP_BASE_URL}/login with incorrect credentials. this is the output of docker logs

2023-01-20 07:17:48: App: GET /images/Logo.png (200) (20ms)
2023-01-20 07:17:48: [error] App: 404: GET /favicon.ico: 1.2.3.4.: Path not found: favicon.ico
2023-01-20 07:17:48: App: GET /favicon.ico (404) (50ms)
2023-01-20 07:18:02: App: POST /login (403) (209ms)
2023-01-20 07:18:02: App: GET /js/jquery.min.js (200) (10ms)
2023-01-20 07:18:02: App: GET /css/main.css (200) (13ms)
2023-01-20 07:18:02: App: GET /js/main.js (200) (13ms)
2023-01-20 07:18:02: App: GET /css/bulma.min.css (200) (23ms)
2023-01-20 07:18:03: App: GET /css/fontawesome/css/all.min.css (200) (14ms)
2023-01-20 07:18:03: App: GET /images/Logo.png (200) (3ms)
2023-01-20 07:19:07: App: POST /login (403) (32ms)
2023-01-20 07:19:07: App: GET /css/main.css (200) (13ms)
2023-01-20 07:19:07: App: GET /css/fontawesome/css/all.min.css (200) (14ms)
2023-01-20 07:19:07: App: GET /js/main.js (200) (21ms)
2023-01-20 07:19:07: App: GET /js/jquery.min.js (200) (15ms)
2023-01-20 07:19:08: App: GET /css/bulma.min.css (200) (35ms)
2023-01-20 07:19:08: App: GET /images/Logo.png (200) (2ms)

Not a word about failed attempts....

just tried to login with incorrect credentials a couple of times via ${APP_BASE_URL}/login

nothin is shown in the docker logs docker logs <container>

2023-01-20 07:17:48: App: GET /images/Logo.png (200) (20ms)
2023-01-20 07:17:48: [error] App: 404: GET /favicon.ico: 192.168.10.20: Path not found: favicon.ico
2023-01-20 07:17:48: App: GET /favicon.ico (404) (50ms)
2023-01-20 07:18:02: App: POST /login (403) (209ms)
2023-01-20 07:18:02: App: GET /js/jquery.min.js (200) (10ms)
2023-01-20 07:18:02: App: GET /css/main.css (200) (13ms)
2023-01-20 07:18:02: App: GET /js/main.js (200) (13ms)
2023-01-20 07:18:02: App: GET /css/bulma.min.css (200) (23ms)
2023-01-20 07:18:03: App: GET /css/fontawesome/css/all.min.css (200) (14ms)
2023-01-20 07:18:03: App: GET /images/Logo.png (200) (3ms)
2023-01-20 07:19:07: App: POST /login (403) (32ms)
2023-01-20 07:19:07: App: GET /css/main.css (200) (13ms)
2023-01-20 07:19:07: App: GET /css/fontawesome/css/all.min.css (200) (14ms)
2023-01-20 07:19:07: App: GET /js/main.js (200) (21ms)
2023-01-20 07:19:07: App: GET /js/jquery.min.js (200) (15ms)
2023-01-20 07:19:08: App: GET /css/bulma.min.css (200) (35ms)
2023-01-20 07:19:08: App: GET /images/Logo.png (200) (2ms)