Skip to content

Enforce the pipe capability in Exim, not just in the web UI#300

Open
kaluang wants to merge 1 commit into
vexim:masterfrom
kaluang:fix/exim-pipe-capability-check
Open

Enforce the pipe capability in Exim, not just in the web UI#300
kaluang wants to merge 1 commit into
vexim:masterfrom
kaluang:fix/exim-pipe-capability-check

Conversation

@kaluang

@kaluang kaluang commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Whether a delivery is piped is currently decided solely by whether the stored smtp field begins with "|"; the routers never check on_piped or the domain's pipe flag. on_avscan and on_spamassassin are already re-checked against the domain in the routers, so pipe is the odd one out.

Add a virtual_piped router that only pipes when the user is on_piped and the domain has pipe enabled, and set forbid_pipe on virtual_domains so a stray "|command" smtp value can't be executed there. Piped users keep working; for everyone else a pipe can no longer be reached.

The catchall and group routers also set a pipe transport, but their data comes from a validated forward address / the group member list rather than the raw smtp field, so they are left unchanged.

I haven't been able to test this against a live Exim setup, so please sanity-check the new router before relying on it.

Whether a delivery is piped is currently decided solely by whether the
stored smtp field begins with "|"; the routers never check on_piped or
the domain's pipe flag. on_avscan and on_spamassassin are already
re-checked against the domain in the routers, so pipe is the odd one out.

Add a virtual_piped router that only pipes when the user is on_piped and
the domain has pipe enabled, and set forbid_pipe on virtual_domains so a
stray "|command" smtp value can't be executed there. Piped users keep
working; for everyone else a pipe can no longer be reached.

The catchall and group routers also set a pipe transport, but their data
comes from a validated forward address / the group member list rather
than the raw smtp field, so they are left unchanged.
@rimas-kudelis

Copy link
Copy Markdown
Collaborator

Thanks! This is a bigger change than I expected though. I hope you don't mind if I keep this PR open for now.

@runout-at

Copy link
Copy Markdown
Contributor

I could look into this and test it on a live system. Please give me some days.

local_part_suffix_optional
.endif
retry_use_local_part
pipe_transport = address_pipe

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed you haven't added file_transport and reply_transport here. Do non-piped emails get ignored by this router?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lookup in virtual_piped only returns something for on_piped users (on a pipe-enabled domain) and that is handled by the address_pipe transport. For everyone else it matches nothing, so the router simply declines and the message falls through to virtual_domains as usual.

That is also why there's no file_transport/reply_transport, this router only ever hands off a |command, never a mailbox file or a reply. The one thing it would not cover is a misconfigured piped account when smtp is not actually a pipe, which would just defer. But I could add a file_transport or an on_piped condition if you prefer.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I was wondering about a misconfigured account, yeah. Will such case not fall through to the next router? If not, perhaps a maybe it would make sense to check that the smtp field has the | character as well?

@runout-at

runout-at commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

This brings me back to an old PR of myself which never got merged: #247

I did already split out the pipe router. But did not introduce checking the on_pipe field and use a users.type field instead. Maybe I came about this while writing the veyimpy frontend.

So it would not be easy to verify your PR on my server.

Please let me know what you think of my approach. and my PR which originally was about introducing LMTP transport.

@runout-at

runout-at commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

btw. the router on my live server working since 2017:

virtual_pipe:
  debug_print = "R: virtual_pipe for $local_part@$domain"
  driver = redirect
  domains = +local_domains
  allow_fail
  data = ${lookup mysql{select smtp from users,domains \
        where users.type='piped' \
                and localpart = '${quote_mysql:$local_part}' \
                and domain = '${quote_mysql:$domain}' \
                and domains.enabled = '1' \
                and users.enabled = '1' \
                and users.domain_id = domains.domain_id}}
  # using local_part_suffixes enables possibility to use user-"something" localparts
  # which could cause you trouble if you're creating email-adresses with dashes in between.
  .ifdef VEXIM_LOCALPART_SUFFIX
    local_part_suffix = VEXIM_LOCALPART_SUFFIX
    local_part_suffix_optional
  .endif
  pipe_transport = vexim_pipe

And yes, I have a few pipe transports running. Eg for redmine.

I just added to my where clause to be more conform to your (and my own) suggestion:

                and LEFT(users.smtp,1)='|' \
                and domains.pipe = '1' \

@runout-at

Copy link
Copy Markdown
Contributor

I just checked. The users table has the type field (it was not introduced only on my server):

type enum('local','alias','catch','fail','piped','admin','site') NOT NULL DEFAULT 'local',`

IMHO the on_pipe field is overkill anyways and I'd prefer to use the type field instead.

We could remove the on_pipe field.

@rimas-kudelis

Copy link
Copy Markdown
Collaborator

Extending type sounds like potentially a good idea, but I have a question: does Exim allow specifying multiple commands to pipe the email to, or specifying a pipe and a non-pipe (like a delivery directory or forward email address)? Like, can I specify that my mailbox a@example.org should deliver email to /var/mail/.../a, forward that email to aa@example.com, and be piped to /usr/bin/somecommand in parallel or in sequence? If it's possible, then by specifying different types for each mailbox, we must first accept that we're limiting that functionality.

@runout-at

runout-at commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

I'm not sure if it is necessary to do forwards and other deliveries for a 'piped'-user. You could use an 'alias'- or normal user account and do a normal delivery + forwarding to a 'piped'-user.

But I think the forward router is/should be placed before the pipe router and so a forward should be possible for 'piped' users. The forward router has no transport defined and will not end/prevent the processing of further routers.

A delivery to an account will afaik not take place after processing the pipe route because the pipe router is before the delivery router and has a transport defined.

I'm not 100% sure if Im right but this doc should fit. See section 10:

https://www.exim.org/exim-html-current/doc/html/spec_html/ch-how_exim_receives_and_delivers_mail.html#SECTrouprecon

After some thinking about it... We should not make things more complicated then necessary. A user account should deliver to a mailbox and a pipe account should deliver to a command. Forwarding to additional addresses (accounts of any type) could be done in both cases.

@MrSleeps

MrSleeps commented Jun 12, 2026

Copy link
Copy Markdown

I think, if you are going to keep pipes in play with vexim, you need to start thinking about the security side of it?

You are opening a users script up via a service that does zero security checks on what it's piping to.
What's the limits on pipes running? How many run concurrently? What's limiting the memory usage? What's stopping sql injection attacks in the users script? How are we checking we aren't causing backscatter? How are we sanitizing input? etc

Exim should drop privileges down to the vexim user when piping to it but if you allow users to run arbitrary scripts via Exim pipes, you're effectively giving them the keys to the /var/vmail kingdom. One compromised user account, one disgruntled employee, or one attacker who discovers a command injection vulnerability could destroy the entire email infrastructure in seconds

The following script, for example, would run under the vexim user (drastic example I know):

#!/bin/bash
recipient="$1"  
echo "Processing email for: $recipient" >> /var/log/mail.log

rm -rf /var/vmail  

You can argue that it's only authorised scripts that you have uploaded but what is to stop someone from uploading a script to their web dir and if you have a ui from there changing the script?

This one needs real consideration into the security implications..

One possible way is to run pipes under a totally different account, one with as few privileges as possible:


my_pipe_transport:
  driver = pipe
  command = /path/to/your/safe-wrapper.sh
  user = your_target_user
  group = your_target_group
  return_output = true
  

Should work, it's been a long time since I have gone anywhere near pipes and email.

@rimas-kudelis

Copy link
Copy Markdown
Collaborator

These are very good points.

I never used pipes myself, but this got me thinking about potential disasters too. I wouldn't mind dropping this feature from Vexim at all. IMO, virtual users have no business running arbitrary commands on their mail host. Feel free to correct me though if you know of a legitimate use example which wouldn't otherwise be possible.

If we keep piping, we must always check if it is enabled for the domain before using it. But also:

  • I think, in the docs, we should discourage admins from enabling it unless they know exactly what they are doing
  • We should strongly advise them that if they choose to enable piping, they should also limit available commands, for example, by making use of the allow_commands directive. Maybe we should even provide a default value for it.

@runout-at

Copy link
Copy Markdown
Contributor

You are right about the concerns on using pipes to a command and I'm very careful about when using this feature (maybe others do not care that much). On my mail servers are no users beyond me and a second person whom I trust completely.

I have one piping user at the moment. This pipes to a script for injecting mails into Redmine. Several years ago I had a similar use case for a ticket-system (RT request tracker).

Both systems bring a script for the MTA to pipe mails into the software per web-api which runs on a different VM or server. I did check those scripts before using it.
Do you have any ideas how we can solve this without the pipe functionality. Or in a more secure way?

I remember @Udera (#150 (comment)) was using pipes for delivering to dovecot-lda. But this can be solved by using LMTP. Not sure about Courier, Cyrus and others.

@MrSleeps

Copy link
Copy Markdown

Honestly, I don't know what is the best way to use pipes. The risk is smaller if it's just your script or you are the only user on your mail server but bugs and exploits come out every day.. What is "safe" today could be a very different story tomorrow (just look at some of the recent kernel CVEs).

I mentioned a few comments up about pipes running under a different user. I guess you could really lock that account down and run it that way? Exim does allow some sanitising of things like addresses:

${sg{$local_part}{[^a-z0-9]}{}}

But then you have the body of the email to contend with and sanitise.

Or I suppose you could have a wrapper script. You appear to know more python than me, but the re module in python could be used to strip out unwanted characters and I'm sure there's some data sanitising modules. Having the data passed to it via stdin would minimise the issue of potentially executing a command when passing data to the script (same way rt-mailgate runs if I remember correctly).

Another alternative I was messing around with earlier today was running the command in a jail under a different user:

pipe_jail:
  driver = pipe
  use_shell = false
  command = /usr/bin/nsjail \
            --chroot /var/lib/mail-jail \
            --user nobody \
            --group nobody \
            --network_none \
            --max_cpus 1 \
            --rlimit_as 128 \
            --time_limit 20 \
            --rw \
            -- /bin/bash /scripts/your-script.sh
            
  user = vexim-pipe
  timeout = 25s

Basically, vexim-pipe calls nsjail, which runs under the nobody user. But it's relying on yet another piece of software written by someone else and I couldn't work out how to get it to play with things like Mariadb.

The theory is the same for podman/docker

pipe_podman:
  driver = pipe
  command = /usr/bin/podman run --rm -i \
            --network none \
            --memory="128m" \
            --cpus="0.5" \
            --user=nobody \
            -v /home/${local_part}/mail_scripts:/script:ro \
            email-sandbox-image \
            /bin/bash /script/user_processor.sh
  
  # Run the Podman command itself as a restricted host user
  user = exim_pipe_runner
  group = exim_pipe_runner

At the end of the day I guess it depends on what you use your email for. If you are a company and this is your main mta that handles your business (critical) emails, or a hosting provider with hundreds/thousands of customers then pipes are, in my opinion, just not worth the risk or hassle. If you are running a little server for you, your other half and your pets then that's your call.

But it's 2026, people are running code through ai to find bugs they can exploit. Our honeypot server which runs an up to date version of WordPress is constantly getting crypto miners on it, 0-day exploits are "popular" these days.. Which is great, because those ips instantly end up in our firewall. That little honeypot is handy for catching spambots too.

Aaand I've gone off topic..

@runout-at

Copy link
Copy Markdown
Contributor

Your comment is very helpful and we are thinking in the same direction.

Just for the record: Pydantic could be used in Python for sanitising input.

Probably splitting out this pipe features to another MTA would make the first MTA more save. I have to think more about this could be done. But this would only need a forwarding account on the main MTA.

An IMAP-fetching script on the side where the receiving software is running could be a solution too. I'll give this approach a try in the next days. A small IMAP-client in python should be not to difficult. It could fetch mails every minute. I'm don't know if the IMAP push feature is reliable.

If there is a good alternative approach I'd vote to remove the 'piped' users from Vexim.
On writing Veximpy it caused me some headache too.

@MrSleeps

MrSleeps commented Jun 13, 2026

Copy link
Copy Markdown

My personal opinion (from experience) is pipes should be removed, they are a massive security weakness and for VExim they certainly should be taken away from "normal" users to setup (the web ui I have just released removes them completely). If the system admin wants to run pipes then that's their choice?

Having a secondary MTA just kind of pushes the issue to another server and lets be honest, how many people installing VExim are going to have a secondary MTA available?

IMAP polling is what we use for our support stuff, polls the IMAP server, checks against the cache and if there's new messages it pulls them down and inserts them into a database. From what I remember the script is relatively basic..

@runout-at

Copy link
Copy Markdown
Contributor

ok... redmine brings a configuration option to fetch emails fom IMAP . https://www.redmine.org/projects/redmine/wiki/RedmineReceivingEmails

Otherwise there exists fetchmail which is used by RT (request tracker https://rt-wiki.bestpractical.com/wiki/ManualEmailConfig) and should be easily usable with any software.

So I'm fine with removing the 'piping' users from Vexim completely. I'd do an entry in the Vexim Wiki to mention the removal and the hint to use fetchmail on the receiving system instead.

@runout-at

Copy link
Copy Markdown
Contributor

Added section to the Wiki: https://github.com/vexim/vexim2/wiki/Security#piping-to-commands

feel free to enhance it.

@runout-at

Copy link
Copy Markdown
Contributor

FYI: I did remove the pipe user from veximpy completely.

@MrSleeps

Copy link
Copy Markdown

I'm still trying to work out a secure way of having them enabled.. There has to be a way!

@rimas-kudelis

Copy link
Copy Markdown
Collaborator

IMO, one secure way to have pipes would be to define specific pipes targeting specific scenarios in Exim config itself (or included snippets). If you wanted to use Vexim for managing such mailboxes, you'd have to select a specific mailbox type instead of typing in the whole pipe command as free text. Arbitrary pipes would then never be possible to use.

I'm not sure if this is possible or feasible though.

@MrSleeps

Copy link
Copy Markdown

That's not actually not a bad way of doing it..

Put something like this in the transports section of the config.

secure_pipe_route_one:
  driver = pipe
  command = /usr/local/bin/the_script $local_part
  user = nobody
  group = nogroup

Add a transport_name column to the users table, name of the transport that was added (secure_pipe_route_one) gets put in there.

Then add a router that looks something like:

route_pipe_by_sql:
  driver = accept
  domains = +your_domains
  # The result of the query should be the name of a defined transport, e.g., "secure_pipe_route_one"
  transport = ${lookup mysql {SELECT u.transport_name \
				FROM users u \
				JOIN domains d ON u.domain_id = d.domain_id \
				WHERE u.localpart='${quote_mysql:$local_part}' \
				AND d.domain='${quote_mysql:$domain}' \
				AND u.enabled=1} \
			   {${value}}{local_delivery}}

Could still keep the type = pipe and and that to the sql?

@rimas-kudelis

rimas-kudelis commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Why not just treat each pipe transport as its own user type?

@runout-at

Copy link
Copy Markdown
Contributor

I think you are missing something in the where of the SQL:

        where users.type='piped' \
                and localpart = '${quote_mysql:$local_part}' \
                and domain = '${quote_mysql:$domain}' \
                and domains.enabled = '1' \
                and users.enabled = '1' \
                and users.domain_id = domains.domain_id \
                and LEFT(users.smtp,1)='|' \

In any case the pipe router should be separated from the virtual_delivery router.

@MrSleeps

Copy link
Copy Markdown

It was just an example..

"Could still keep the type = pipe"

@MrSleeps

Copy link
Copy Markdown

Why not just treat each pipe transport as its own user type?

How do you mean?

@runout-at

Copy link
Copy Markdown
Contributor

Why not just treat each pipe transport as its own user type?

How would you manage all these user types in the DB? At the moment the type field is an enum. Maybe in an extra table?

@runout-at

Copy link
Copy Markdown
Contributor

Exim brings a procmail router/transport. Would it be possible to use it with vexim? Does it make sense to use this instead?

@rimas-kudelis

Copy link
Copy Markdown
Collaborator

Why not just treat each pipe transport as its own user type?

How would you manage all these user types in the DB? At the moment the type field is an enum. Maybe in an extra table?

Well, we have two options:

  1. change the list of allowed enum values when necessary
  2. change the column type to string

@MrSleeps

Copy link
Copy Markdown
  1. change the list of allowed enum values when necessary
  2. change the column type to string

I would say update the enum when it's needed, string opens up the potential for typos (coming from a man who makes plenty of them).

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

Successfully merging this pull request may close these issues.

4 participants