diff --git a/.codeclimate.yml b/.codeclimate.yml index dfe3bb6..c889eb8 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,10 +1,10 @@ engines: eslint: enabled: true - channel: "eslint-6" + channel: 'eslint-8' config: - config: ".eslintrc.yaml" + config: '.eslintrc.yaml' ratings: - paths: - - "**.js" + paths: + - '**.js' diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 6e9dc9e..035a400 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -2,23 +2,6 @@ env: node: true es6: true mocha: true + es2022: true -plugins: - - haraka - -extends: - - eslint:recommended - - plugin:haraka/recommended - -rules: - indent: [2, 2, {"SwitchCase": 1}] - -root: true - -globals: - OK: true - CONT: true - DENY: true - DENYSOFT: true - DENYDISCONNECT: true - DENYSOFTDISCONNECT: true +extends: ['@haraka'] diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 0dccc85..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,11 +0,0 @@ -### system info - -Please report your OS, Node version, and Haraka version by running this shell script on your Haraka server and replacing this section with the output. - -echo "Haraka | $(haraka -v)"; echo " --- | :--- "; echo "Node | $(node -v)"; echo "OS | $(uname -a)"; echo "openssl | $(openssl version)" - -### Expected behavior - -### Observed behavior - -### Steps to reproduce diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a3b4db0..5ccd7ed 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,12 +1,13 @@ -Fixes # - Changes proposed in this pull request: -- -- + +- +- + +Fixes # Checklist: + - [ ] docs updated - [ ] tests updated - [ ] Changes.md updated - [ ] package.json.version bumped -- [ ] published to NPM (will be done by @core) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..df04b68 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'monthly' + allow: + - dependency-type: production diff --git a/.github/workflows/ci-test-win.yml b/.github/workflows/ci-test-win.yml deleted file mode 100644 index d27a59a..0000000 --- a/.github/workflows/ci-test-win.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Plugin Tests - Windows - -on: [ push, pull_request ] - -jobs: - - ci-test-win: - - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ windows-latest ] - node-version: [10.x, 12.x, 14.x] - fail-fast: false - - steps: - - uses: actions/checkout@v1 - name: Checkout Plugin - with: - fetch-depth: 1 - - - uses: actions/setup-node@v1 - name: Use Node.js ${{ matrix.node-version }} - with: - node-version: ${{ matrix.node-version }} - - - name: npm install and test - run: | - npm install - npm run test - - env: - CI: true diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml deleted file mode 100644 index 49f1ec2..0000000 --- a/.github/workflows/ci-test.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Plugin Tests - -on: [ push, pull_request ] - -jobs: - - ci-test: - - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ ubuntu-latest ] - node-version: [10.x, 12.x, 14.x] - fail-fast: false - - steps: - - uses: actions/checkout@v1 - name: Checkout Plugin - with: - fetch-depth: 1 - - - uses: actions/setup-node@v1 - name: Use Node.js ${{ matrix.node-version }} - with: - node-version: ${{ matrix.node-version }} - - - name: npm install - run: npm install - - - name: Run test suite - run: npm run test - - env: - CI: true - - # services: - # redis: - # image: redis - # ports: - # - 6379/tcp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d01042 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: [push, pull_request] + +env: + CI: true + +jobs: + lint: + uses: haraka/.github/.github/workflows/lint.yml@master + + # coverage: + # uses: haraka/.github/.github/workflows/coverage.yml@master + # secrets: inherit + + ubuntu: + needs: [lint] + uses: haraka/.github/.github/workflows/ubuntu.yml@master + + windows: + needs: [lint] + uses: haraka/.github/.github/workflows/windows.yml@master diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..816e8c3 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,13 @@ +name: 'CodeQL' + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + - cron: '18 7 * * 4' + +jobs: + codeql: + uses: haraka/.github/.github/workflows/codeql.yml@master diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml deleted file mode 100644 index 896f9c5..0000000 --- a/.github/workflows/coveralls.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: [ pull_request ] - -name: Test Coverage - -jobs: - - coverage: - name: Codecov - runs-on: ubuntu-latest - - steps: - - - uses: actions/checkout@master - name: Checkout Plugin - with: - fetch-depth: 1 - - - name: Use Node.js 10 - uses: actions/setup-node@master - with: - node-version: 10.x - - - name: install, run - run: | - npm install - npm install --no-save nyc codecov - npm run cover - - - name: Coveralls - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.github_token }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 9f816d8..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Lint - -on: [ push, pull_request ] - -jobs: - - lint: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [ 12.x ] - - steps: - - uses: actions/checkout@v1 - name: Checkout Plugin - with: - fetch-depth: 1 - - - uses: actions/setup-node@v1 - name: Use Node.js ${{ matrix.node-version }} - with: - node-version: ${{ matrix.node-version }} - - - name: npm install - run: npm install - - - name: Lint using eslint - run: npm run lint - - env: - CI: true \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..e81c15f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,16 @@ +name: publish + +on: + push: + branches: + - master + paths: + - package.json + +env: + CI: true + +jobs: + publish: + uses: haraka/.github/.github/workflows/publish.yml@master + secrets: inherit diff --git a/.gitignore b/.gitignore index f8faa6b..625981f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,9 @@ jspm_packages .node_repl_history package-lock.json +bower_components +# Optional npm cache directory +.npmrc +.idea +.DS_Store +haraka-update.sh \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a8e94cb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".release"] + path = .release + url = git@github.com:msimerson/.release.git diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8ded5e0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,2 @@ +singleQuote: true +semi: false diff --git a/.release b/.release new file mode 160000 index 0000000..7cd5707 --- /dev/null +++ b/.release @@ -0,0 +1 @@ +Subproject commit 7cd5707f7d69f8d4dca1ec407ada911890e59d0a diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..62d2cb9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +### Unreleased + +- + +### [1.0.2] - 2024-04-29 + +- repackaged from haraka/Haraka as NPM module + +[1.0.2]: https://github.com/haraka/haraka-plugin-template/releases/tag/1.0.2 diff --git a/Changes.md b/Changes.md deleted file mode 100644 index fe2c02d..0000000 --- a/Changes.md +++ /dev/null @@ -1,4 +0,0 @@ - -# 1.0.0 - 201_-__-__ - -- initial release diff --git a/LICENSE b/LICENSE index 29f9810..d265d43 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Haraka +Copyright (c) 2017-2024 Haraka Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c8047a7..322caa5 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,81 @@ -[![Unix Build Status][ci-img]][ci-url] -[![Windows Build Status][ci-win-img]][ci-win-url] +[![CI Test Status][ci-img]][ci-url] [![Code Climate][clim-img]][clim-url] -[![NPM][npm-img]][npm-url] -# haraka-plugin-template +[![NPM][npm-img]][npm-url] -Clone me, to create a new plugin! +# haraka-plugin-bounce -# Template Instructions +# bounce -These instructions will not self-destruct after use. Use and destroy. +Provide options for bounce processing. -See also, [How to Write a Plugin](https://github.com/haraka/Haraka/wiki/Write-a-Plugin) and [Plugins.md](https://github.com/haraka/Haraka/blob/master/docs/Plugins.md) for additional plugin writing information. +## Configuration -## Create a new repo for your plugin +Each feature can be enabled/disabled with a true/false toggle in the `[check]` +section of `config/bounce.ini`: -Haraka plugins are named like `haraka-plugin-something`. All the namespace after `haraka-plugin-` is yours for the taking. Please check the [Plugins](https://github.com/haraka/Haraka/blob/master/Plugins.md) page and a Google search to see what plugins already exist. +Some features can have rejections disabled in the [reject] section. -Once you've settled on a name, create the GitHub repo. On the repo's main page, click the _Clone or download_ button and copy the URL. Then paste that URL into a local ENV variable with a command like this: +```ini +[check] +reject_all=false +single_recipient=true +empty_return_path=true +bad_rcpt=true +bounce_spf=true +non_local_msgid=true -```sh -export MY_GITHUB_ORG=haraka -export MY_PLUGIN_NAME=haraka-plugin-SOMETHING +[reject] +single_recipient=true +empty_return_path=true +bounce_spf=false +non_local_msgid=false ``` -Clone and rename the template repo: +## Features -```sh -git clone git@github.com:haraka/haraka-plugin-template.git -mv haraka-plugin-template $MY_PLUGIN_NAME -cd $MY_PLUGIN_NAME -git remote rm origin -git remote add origin "git@github.com:$MY_GITHUB_ORG/$MY_PLUGIN_NAME.git" -``` +### reject_all -Now you'll have a local git repo to begin authoring your plugin +When enabled, blocks all bounce messages using the simple rule of checking for `MAIL FROM:<>`. -## rename boilerplate +It is generally a bad idea to block all bounces. This option can be useful for mail servers at domains with frequent spoofing and few or no human users. -Replaces all uses of the word `template` with your plugin's name. +### single_recipient -./redress.sh [something] +Valid bounces have a single recipient. Assure that the message really is a bounce by enforcing bounces to be addressed to a single recipient. -You'll then be prompted to update package.json and then force push this repo onto the GitHub repo you've created earlier. +This check is skipped for relays or hosts with a private IP, this is because Microsoft Exchange distribution lists will send messages to list members with a null return-path when the 'Do not send delivery reports' option is enabled (yes, really...). +### empty_return_path -# Add your content here +Valid bounces should have an empty return path. Test for the presence of the Return-Path header in bounces and disallow. -## INSTALL +### bad_rcpt -```sh -cd /path/to/local/haraka -npm install haraka-plugin-template -echo "template" >> config/plugins -service haraka restart -``` +Disallow bounces to email addresses listed in `config/bounce_bad_rcpt`. -### Configuration +Include email addresses in that file that should _never_ receive bounce messages. Examples of email addresses that should be listed are: autoresponders, do-not-reply@example.com, dmarc-feedback@example.com, and any other email addresses used solely for machine generated messages. -If the default configuration is not sufficient, copy the config file from the distribution into your haraka config dir and then modify it: +### bounce_spf -```sh -cp node_modules/haraka-plugin-template/config/template.ini config/template.ini -$EDITOR config/template.ini -``` +Parses the message body and any MIME parts for Received: headers and strips out the IP addresses of each Received hop and then checks what the SPF result would have been if bounced message had been sent by that hop. + +If no 'Pass' result is found, then this test will fail. If SPF returns 'None', 'TempError' or 'PermError' then the test will be skipped. ## USAGE +Add `bounce` to Haraka's config/plugins file. If desired, install and customize a local bounce.ini. + +```sh +cp node_modules/haraka-plugin-bounce/config/bounce.ini config/bounce.ini +$EDITOR config/bounce.ini +``` -[ci-img]: https://github.com/haraka/haraka-plugin-template/workflows/Plugin%20Tests/badge.svg -[ci-url]: https://github.com/haraka/haraka-plugin-template/actions?query=workflow%3A%22Plugin+Tests%22 -[ci-win-img]: https://github.com/haraka/haraka-plugin-template/workflows/Plugin%20Tests%20-%20Windows/badge.svg -[ci-win-url]: https://github.com/haraka/haraka-plugin-template/actions?query=workflow%3A%22Plugin+Tests+-+Windows%22 -[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-template/badges/gpa.svg -[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-template -[npm-img]: https://nodei.co/npm/haraka-plugin-template.png -[npm-url]: https://www.npmjs.com/package/haraka-plugin-template + +[ci-img]: https://github.com/haraka/haraka-plugin-bounce/actions/workflows/ci.yml/badge.svg +[ci-url]: https://github.com/haraka/haraka-plugin-bounce/actions/workflows/ci.yml +[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-bounce/badges/gpa.svg +[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-bounce +[npm-img]: https://nodei.co/npm/haraka-plugin-bounce.png +[npm-url]: https://www.npmjs.com/package/haraka-plugin-bounce diff --git a/config/bounce.ini b/config/bounce.ini new file mode 100644 index 0000000..4f30006 --- /dev/null +++ b/config/bounce.ini @@ -0,0 +1,15 @@ +; config/bounce_bad_rcpt: addresses that should never get bounces + + +[check] +single_recipient=true +empty_return_path=true +bad_rcpt=true + +; reject all bounce messages (generally a bad idea) +reject_all=false + + +[reject] +single_recipient=true +empty_return_path=true diff --git a/config/template.ini b/config/template.ini deleted file mode 100644 index 2a92888..0000000 --- a/config/template.ini +++ /dev/null @@ -1,2 +0,0 @@ - -[main] diff --git a/index.js b/index.js index 7c1b269..ae808e4 100644 --- a/index.js +++ b/index.js @@ -1,20 +1,397 @@ -'use strict' +// bounce tests +const tlds = require('haraka-tld') +const { SPF } = require('haraka-plugin-spf') +const net_utils = require('haraka-net-utils') exports.register = function () { - this.load_template_ini() + this.load_bounce_ini() + this.load_bounce_bad_rcpt() + + this.register_hook('mail', 'reject_all') + this.register_hook('data', 'single_recipient') + this.register_hook('data', 'bad_rcpt') + this.register_hook('data_post', 'empty_return_path') + this.register_hook('data', 'bounce_spf_enable') + this.register_hook('data_post', 'bounce_spf') + this.register_hook('data_post', 'non_local_msgid') +} + +exports.load_bounce_bad_rcpt = function () { + const new_list = this.config.get('bounce_bad_rcpt', 'list', () => { + this.load_bounce_bad_rcpt() + }) + + const invalids = {} + for (const element of new_list) { + invalids[element] = true + } + + this.cfg.invalid_addrs = invalids +} + +exports.load_bounce_ini = function () { + this.cfg = this.config.get( + 'bounce.ini', + { + booleans: [ + '-check.reject_all', + '+check.single_recipient', + '-check.empty_return_path', + '+check.bad_rcpt', + '+check.bounce_spf', + '+check.non_local_msgid', + + '+reject.single_recipient', + '-reject.empty_return_path', + '-reject.bounce_spf', + '-reject.non_local_msgid', + ], + }, + () => { + this.load_bounce_ini() + }, + ) +} + +exports.reject_all = function (next, connection, params) { + if (!this.cfg.check.reject_all) return next() + + const mail_from = params[0] + // bounce messages are from null senders + if (!this.has_null_sender(connection, mail_from)) return next() + + connection.transaction.results.add(this, { + fail: 'bounces_accepted', + emit: true, + }) + next(DENY, 'No bounces accepted here') +} + +exports.single_recipient = function (next, connection) { + if (!this?.cfg?.check?.single_recipient) return next() + if (!this.has_null_sender(connection)) return next() + const { transaction, relaying, remote } = connection + + // Valid bounces have a single recipient + if (transaction.rcpt_to.length === 1) { + transaction.results.add(this, { pass: 'single_recipient', emit: true }) + return next() + } + + // Skip this check for relays or private_ips. This is because Microsoft + // Exchange will send mail to distribution groups using the null-sender + // if the option 'Do not send delivery reports' is checked + if (relaying) { + transaction.results.add(this, { + skip: 'single_recipient(relay)', + emit: true, + }) + return next() + } + if (remote.is_private) { + transaction.results.add(this, { + skip: 'single_recipient(private_ip)', + emit: true, + }) + return next() + } + + connection.loginfo( + this, + `bounce with too many recipients to: ${transaction.rcpt_to.join(',')}`, + ) + + transaction.results.add(this, { fail: 'single_recipient', emit: true }) + + if (!this.cfg.reject.single_recipient) return next() + + next(DENY, 'this bounce message does not have 1 recipient') +} + +exports.empty_return_path = function (next, connection) { + if (!this.cfg.check.empty_return_path) return next() + if (!this.has_null_sender(connection)) return next() + + const { transaction } = connection + // Bounce messages generally do not have a Return-Path set. This checks + // for that. But whether it should is worth questioning... + + // On Jan 20, 2014, Matt Simerson examined the most recent 50,000 mail + // connections for the presence of Return-Path in bounce messages. I + // found 14 hits, 12 of which were from Google, in response to + // undeliverable DMARC reports (IE, automated messages that Google + // shouldn't have replied to). Another appears to be a valid bounce from + // a poorly configured mailer, and the 14th was a confirmed spam kill. + // Unless new data demonstrate otherwise, this should remain disabled. + + // Return-Path, aka Reverse-PATH, Envelope FROM, RFC5321.MailFrom + // validate that the Return-Path header is empty, RFC 3834 + + const rp = transaction.header.get('Return-Path') + if (!rp) { + transaction.results.add(this, { pass: 'empty_return_path' }) + return next() + } + + if (rp === '<>') { + transaction.results.add(this, { pass: 'empty_return_path' }) + return next() + } + + transaction.results.add(this, { fail: 'empty_return_path', emit: true }) + return next(DENY, 'bounce with non-empty Return-Path (RFC 3834)') +} + +exports.bad_rcpt = function (next, connection) { + if (!this.cfg.check.bad_rcpt) return next() + if (!this.has_null_sender(connection)) return next() + if (!this.cfg.invalid_addrs) return next() + + const { transaction } = connection + for (const element of transaction.rcpt_to) { + const rcpt = element.address() + if (!this.cfg.invalid_addrs[rcpt]) continue + transaction.results.add(this, { fail: 'bad_rcpt', emit: true }) + return next(DENY, 'That recipient does not accept bounces') + } + transaction.results.add(this, { pass: 'bad_rcpt' }) + + next() } -exports.load_template_ini = function () { - const plugin = this +exports.has_null_sender = function (connection, mail_from) { + const transaction = connection?.transaction + if (!transaction) return false + + if (!mail_from) mail_from = transaction.mail_from + + // bounces have a null sender. + // null sender could also be tested with mail_from.user + // Why would isNull() exist if it wasn't the right way to test this? + if (mail_from.isNull()) { + transaction.results.add(this, { isa: 'yes' }) + return true + } + + transaction.results.add(this, { isa: 'no' }) + return false +} + +const message_id_re = /^Message-ID:\s*(]+>?)/gim + +function find_message_id_headers(headers, body, connection, self) { + if (!body) return + + let match + while ((match = message_id_re.exec(body.bodytext))) { + const mid = match[1] + headers[mid] = true + } + + for (let i = 0, l = body.children.length; i < l; i++) { + // Recure to any MIME children + find_message_id_headers(headers, body.children[i], connection, self) + } +} + +exports.non_local_msgid = function (next, connection) { + if (!this.cfg.check.non_local_msgid) return next() + if (!this.has_null_sender(connection)) return next() + + const transaction = connection?.transaction + if (!transaction) return next() + // Bounce messages usually contain the headers of the original message + // in the body. This parses the body, searching for the Message-ID header. + // It then inspects the contents of that header, extracting the domain part, + // and then checks to see if that domain is local to this server. + + // NOTE: this only works reliably if *every* message sent has a local + // domain in the Message-ID. In practice, that means outbound MXes MUST + // check Message-ID on outbound and modify non-conforming Message-IDs. + // + // NOTE 2: Searching the bodytext of a bounce is too simple. The bounce + // message should exist as a MIME Encoded part. See here for ideas + // http://lamsonproject.org/blog/2009-07-09.html + // http://lamsonproject.org/docs/bounce_detection.html + + let matches = {} + find_message_id_headers(matches, transaction.body, connection, this) + matches = Object.keys(matches) + connection.logdebug(this, `found Message-IDs: ${matches.join(', ')}`) + + if (!matches.length) { + connection.loginfo(this, 'no Message-ID matches') + transaction.results.add(this, { fail: 'Message-ID' }) + if (!this.cfg.reject.non_local_msgid) return next() + return next( + DENY, + `bounce without Message-ID in headers, unable to verify that I sent it`, + ) + } + + const domains = [] + for (const match of matches) { + const res = match.match(/@([^>]*)>?/i) + if (!res) continue + domains.push(res[1]) + } + + if (domains.length === 0) { + connection.loginfo(this, 'no domain(s) parsed from Message-ID headers') + transaction.results.add(this, { fail: 'Message-ID parseable' }) + if (!this.cfg.reject.non_local_msgid) return next() + return next(DENY, `bounce with invalid Message-ID, I didn't send it.`) + } + + connection.logdebug(this, domains) + + const valid_domains = [] + for (const domain of domains) { + const org_dom = tlds.get_organizational_domain(domain) + if (!org_dom) { + continue + } + valid_domains.push(org_dom) + } + + if (valid_domains.length === 0) { + transaction.results.add(this, { fail: 'Message-ID valid domain' }) + if (!this.cfg.reject.non_local_msgid) return next() + return next( + DENY, + `bounce Message-ID without valid domain, I didn't send it.`, + ) + } + + next() + + /* The code below needs some kind of test to say the domain isn't local. + this would be hard to do without knowing how you have Haraka configured. + e.g. it could be config/host_list, or it could be some other way. + - hence I added the return next() above or this test can never be correct. + */ + // we wouldn't have accepted the bounce if the recipient wasn't local + // transaction.results.add(plugin, + // {fail: 'Message-ID not local', emit: true }); + // if (!plugin.cfg.reject.non_local_msgid) return next(); + // return next(DENY, "bounce with non-local Message-ID (RFC 3834)"); +} + +// Lazy regexp to get IPs from Received: headers in bounces +const received_re = net_utils.get_ipany_re( + '^Received:[\\s\\S]*?[\\[\\(](?:IPv6:)?', + '[\\]\\)]', +) + +function find_received_headers(ips, body, connection, self) { + if (!body) return + let match + while ((match = received_re.exec(body.bodytext))) { + const ip = match[1] + if (net_utils.is_private_ip(ip)) continue + ips[ip] = true + } + for (let i = 0, l = body.children.length; i < l; i++) { + // Recurse in any MIME children + find_received_headers(ips, body.children[i], connection, self) + } +} + +exports.bounce_spf_enable = function (next, connection) { + if (!connection.transaction) return next() + if (this.cfg.check.bounce_spf) { + connection.transaction.parse_body = true + } + next() +} + +exports.bounce_spf = function (next, connection) { + if (!this.cfg.check.bounce_spf) return next() + if (!this.has_null_sender(connection)) return next() + + const txn = connection?.transaction + if (!txn) return next() + + // Recurse through all textual parts and store all parsed IPs + // in an object to remove any duplicates which might appear. + let ips = {} + find_received_headers(ips, txn.body, connection, this) + ips = Object.keys(ips) + if (!ips.length) { + connection.loginfo(this, 'No received headers found in message') + return next() + } + + connection.logdebug(this, `found IPs to check: ${ips.join(', ')}`) + + let pending = 0 + let aborted = false + let called_cb = false + let timer + + function run_cb(abort, retval, msg) { + if (aborted) return + if (abort) aborted = true + if (!aborted && pending > 0) return + if (called_cb) return + clearTimeout(timer) + called_cb = true + next(retval, msg) + } + + timer = setTimeout( + () => { + connection.logerror(this, 'Timed out') + txn.results.add(this, { skip: 'bounce_spf(timeout)' }) + return run_cb(true) + }, + (this.timeout - 1) * 1000, + ) - plugin.cfg = plugin.config.get('template.ini', { - booleans: [ - '+enabled', // plugin.cfg.main.enabled=true - '-disabled', // plugin.cfg.main.disabled=false - '+feature_section.yes' // plugin.cfg.feature_section.yes=true - ] - }, - function () { - plugin.load_example_ini() + ips.forEach((ip) => { + if (aborted) return + const spf = new SPF() + pending++ + spf.check_host( + ip, + txn.rcpt_to[0].host, + txn.rcpt_to[0].address(), + (err, result) => { + if (aborted) return + pending-- + if (err) { + connection.logerror(this, err.message) + return run_cb() + } + connection.logdebug(this, `ip=${ip} spf_result=${spf.result(result)}`) + switch (result) { + case spf.SPF_NONE: + // falls through, domain doesn't publish an SPF record + case spf.SPF_TEMPERROR: + case spf.SPF_PERMERROR: + // Abort as all subsequent lookups will return this + connection.logdebug( + this, + `Aborted: SPF returned ${spf.result(result)}`, + ) + txn.results.add(this, { skip: 'bounce_spf' }) + return run_cb(true) + case spf.SPF_PASS: + // Presume this is a valid bounce + // TODO: this could be spoofed; could weight each IP to combat + connection.loginfo(this, `Valid bounce originated from ${ip}`) + txn.results.add(this, { pass: 'bounce_spf' }) + return run_cb(true) + } + if (pending === 0 && !aborted) { + // We've checked all the IPs and none of them returned Pass + txn.results.add(this, { fail: 'bounce_spf', emit: true }) + if (!this.cfg.reject.bounce_spf) return run_cb() + run_cb(false, DENY, 'Invalid bounce (spoofed sender)') + } + }, + ) + // No lookups run for some reason + if (pending === 0 && !aborted) run_cb() }) } diff --git a/package.json b/package.json index 123dc72..bdcd139 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,46 @@ { - "name": "haraka-plugin-template", - "version": "1.0.0", - "description": "Haraka plugin that frobnicates email connections", + "name": "haraka-plugin-bounce", + "version": "1.0.2", + "description": "Provide options for Haraka bounce processing", "main": "index.js", + "files": [ + "CHANGELOG.md", + "config" + ], "scripts": { - "lint": "npx eslint *.js test/*.js", - "lintfix": "npx eslint --fix *.js test/*.js", - "cover": "NODE_ENV=cov npx nyc --reporter=lcovonly npm run test", + "format": "npm run prettier:fix && npm run lint:fix", + "lint": "npx eslint@^8 *.js test", + "lint:fix": "npx eslint@^8 *.js test --fix", + "prettier": "npx prettier . --check", + "prettier:fix": "npx prettier . --write --log-level=warn", + "test": "npx mocha", "versions": "npx dependency-version-checker check", - "test": "npx mocha" + "versions:fix": "npx dependency-version-checker update" }, "repository": { "type": "git", - "url": "git+https://github.com/haraka/haraka-plugin-template.git" + "url": "git+https://github.com/haraka/haraka-plugin-bounce.git" }, "keywords": [ "haraka", "plugin", - "template" + "bounce" ], - "author": "Welcome Member ", + "author": "Haraka Team ", "license": "MIT", "bugs": { - "url": "https://github.com/haraka/haraka-plugin-template/issues" + "url": "https://github.com/haraka/haraka-plugin-bounce/issues" + }, + "homepage": "https://github.com/haraka/haraka-plugin-bounce#readme", + "dependencies": { + "address-rfc2821": "^2.1.2", + "haraka-net-utils": "^1.6.0", + "haraka-plugin-spf": "^1.2.5", + "haraka-tld": "^1.2.1" }, - "homepage": "https://github.com/haraka/haraka-plugin-template#readme", "devDependencies": { - "eslint": "*", - "eslint-plugin-haraka": "*", - "haraka-test-fixtures": "*", - "mocha": "*" + "@haraka/eslint-config": "1.1.3", + "haraka-email-message": "^1.2.3", + "haraka-test-fixtures": "1.3.5" } } diff --git a/redress.sh b/redress.sh deleted file mode 100755 index ca9ff96..0000000 --- a/redress.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh - -if [ -z "$1" ]; then - echo "$0 something" - exit -fi - -sed -i '' -e "s/template/${1}/g" README.md - -sed -i '' \ - -e "s/template/${1}/g" \ - -e "s/template\.ini/$1.ini/" \ - test/index.js - -sed -i '' -e "s/template/${1}/g" package.json -sed -i '' \ - -e "s/_template/_${1}/g" \ - -e "s/template\.ini/$1.ini/" \ - index.js - -git mv config/template.ini "config/$1.ini" -git add package.json README.md index.js test config -git commit -m "renamed template to $1" -npm install -npm run lint && npm test || exit 1 -git rm redress.sh - -echo "success!" -echo "" -echo "Next Steps: update package.json and force push this onto your repo:" -echo "" -echo " \$EDITOR package.json" -echo " git push --set-upstream origin master -f" -echo "" diff --git a/test/index.js b/test/index.js index 3c98977..d675984 100644 --- a/test/index.js +++ b/test/index.js @@ -1,36 +1,338 @@ +'use strict' +const assert = require('node:assert') -// node.js built-in modules -const assert = require('assert') - -// npm modules +const Address = require('address-rfc2821') const fixtures = require('haraka-test-fixtures') +const message = require('haraka-email-message') -// start of tests -// assert: https://nodejs.org/api/assert.html -// mocha: http://mochajs.org +const Connection = fixtures.connection -beforeEach(function (done) { - this.plugin = new fixtures.plugin('template') - done() // if a test hangs, assure you called done() -}) +const _set_up = (done) => { + this.plugin = new fixtures.plugin('bounce') + this.plugin.cfg = { + main: {}, + check: { + reject_all: false, + single_recipient: true, + empty_return_path: true, + bad_rcpt: true, + non_local_msgid: true, + }, + reject: { + single_recipient: true, + empty_return_path: true, + non_local_msgid: true, + }, + invalid_addrs: { 'test@bad1.com': true, 'test@bad2.com': true }, + } + + this.connection = Connection.createConnection() + this.connection.remote.ip = '8.8.8.8' + this.connection.transaction = { + header: new message.Header(), + results: new fixtures.results(this.plugin), + } + + done() +} + +describe('plugins/bounce', () => { + describe('load_configs', () => { + beforeEach(_set_up) -describe('template', function () { - it('loads', function (done) { - assert.ok(this.plugin) - done() + it('load_bounce_ini', () => { + this.plugin.load_bounce_ini() + assert.ok(this.plugin.cfg.main) + assert.ok(this.plugin.cfg.check) + assert.ok(this.plugin.cfg.reject) + }) + + it('load_bounce_bad_rcpt', () => { + this.plugin.load_bounce_bad_rcpt() + assert.ok(this.plugin.cfg.main) + assert.ok(this.plugin.cfg.check) + assert.ok(this.plugin.cfg.reject) + }) }) -}) -describe('load_template_ini', function () { - it('loads template.ini from config/template.ini', function (done) { - this.plugin.load_template_ini() - assert.ok(this.plugin.cfg) - done() + describe('reject_all', () => { + beforeEach(_set_up) + + it('disabled', (done) => { + this.connection.transaction.mail_from = new Address.Address( + '', + ) + this.connection.transaction.rcpt_to = [ + new Address.Address('test@any.com'), + ] + this.plugin.cfg.check.reject_all = false + this.plugin.reject_all( + (rc) => { + assert.equal(rc, undefined) + done() + }, + this.connection, + new Address.Address(''), + ) + }) + + it('not bounce ok', (done) => { + this.connection.transaction.mail_from = new Address.Address( + '', + ) + this.connection.transaction.rcpt_to = [ + new Address.Address('test@any.com'), + ] + this.plugin.cfg.check.reject_all = true + this.plugin.reject_all( + (code) => { + assert.equal(code, undefined) + done() + }, + this.connection, + new Address.Address(''), + ) + }) + + it('bounce rejected', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@any.com'), + ] + this.plugin.cfg.check.reject_all = true + this.plugin.reject_all( + (code) => { + assert.equal(code, DENY) + done() + }, + this.connection, + new Address.Address('<>'), + ) + }) + }) + + describe('empty_return_path', () => { + beforeEach(_set_up) + + it('none', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@good.com'), + ] + this.plugin.empty_return_path((rc) => { + assert.equal(rc, undefined) + done() + }, this.connection) + }) + + it('has', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@good.com'), + ] + this.connection.transaction.header.add( + 'Return-Path', + "Content doesn't matter", + ) + this.plugin.empty_return_path((rc) => { + assert.equal(rc, DENY) + done() + }, this.connection) + }) }) - it('initializes enabled boolean', function (done) { - this.plugin.load_template_ini() - assert.equal(this.plugin.cfg.main.enabled, true, this.plugin.cfg) - done() + describe('non_local_msgid', () => { + beforeEach(_set_up) + + it('no_msgid_in_headers', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@good.com'), + ] + this.connection.transaction.body = new message.Body() + this.connection.transaction.body.bodytext = '' + this.plugin.non_local_msgid((rc) => { + assert.equal(rc, DENY) + done() + }, this.connection) + }) + + it('no_domains_in_msgid', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@good.com'), + ] + this.connection.transaction.body = new message.Body() + this.connection.transaction.body.bodytext = 'Message-ID:' + this.plugin.non_local_msgid((rc) => { + assert.equal(rc, DENY) + done() + }, this.connection) + }) + + it('invalid_domain', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@good.com'), + ] + this.connection.transaction.body = new message.Body() + this.connection.transaction.body.bodytext = + 'Message-ID: ' + this.plugin.non_local_msgid((rc, msg) => { + assert.equal(rc, DENY) + assert.ok(/without valid domain/.test(msg)) + done() + }, this.connection) + }) + /* - commented out because the code looks bogus to me - see comment in plugins/bounce.js - @baudehlo + it('non-local': function, () => { + this.connection.transaction.mail_from= new Address.Address('<>'); + this.connection.transaction.rcpt_to= [ new Address.Address('test@good.com') ]; + this.connection.transaction.body = new message.Body(); + this.connection.transaction.body.bodytext = 'Message-ID: '; + this.plugin.non_local_msgid((rc, msg) { + assert.equal(rc, DENY); + assert.ok(/non-local Message-ID/.test(msg)); + }, this.connection); + }) + */ + }) + + describe('single_recipient', () => { + beforeEach(_set_up) + + it('valid', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@good.com'), + ] + this.plugin.single_recipient((rc) => { + assert.equal(rc, undefined) + done() + }, this.connection) + }) + it('invalid', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@good.com'), + new Address.Address('test2@good.com'), + ] + this.plugin.single_recipient((rc) => { + assert.equal(rc, DENY) + done() + }, this.connection) + }) + it('test@example.com', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@example.com'), + ] + this.plugin.single_recipient((rc) => { + assert.equal(rc, undefined) + done() + }, this.connection) + }) + + it('test@example.com,test2@example.com', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test1@example.com'), + new Address.Address('test2@example.com'), + ] + this.plugin.single_recipient((rc) => { + assert.equal(rc, DENY) + done() + }, this.connection) + }) + }) + + describe('bad_rcpt', () => { + beforeEach(_set_up) + + it('test@good.com', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@good.com'), + ] + this.plugin.bad_rcpt((rc) => { + assert.equal(rc, undefined) + done() + }, this.connection) + }) + + it('test@bad1.com', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@bad1.com'), + ] + this.plugin.cfg.invalid_addrs = { + 'test@bad1.com': true, + 'test@bad2.com': true, + } + this.plugin.bad_rcpt((rc) => { + assert.equal(rc, DENY) + done() + }, this.connection) + }) + + it('test@bad1.com, test@bad2.com', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@bad1.com'), + new Address.Address('test@bad2.com'), + ] + this.plugin.cfg.invalid_addrs = { + 'test@bad1.com': true, + 'test@bad2.com': true, + } + this.plugin.bad_rcpt((rc) => { + assert.equal(rc, DENY) + done() + }, this.connection) + }) + + it('test@good.com, test@bad2.com', (done) => { + this.connection.transaction.mail_from = new Address.Address('<>') + this.connection.transaction.rcpt_to = [ + new Address.Address('test@good.com'), + new Address.Address('test@bad2.com'), + ] + this.plugin.cfg.invalid_addrs = { + 'test@bad1.com': true, + 'test@bad2.com': true, + } + this.plugin.bad_rcpt((rc) => { + assert.equal(rc, DENY) + done() + }, this.connection) + }) + }) + + describe('has_null_sender', () => { + beforeEach(_set_up) + + it('<>', () => { + this.connection.transaction.mail_from = new Address.Address('<>') + assert.equal(this.plugin.has_null_sender(this.connection), true) + }) + + it(' ', () => { + this.connection.transaction.mail_from = new Address.Address('') + assert.equal(this.plugin.has_null_sender(this.connection), true) + }) + + it('user@example', () => { + this.connection.transaction.mail_from = new Address.Address( + 'user@example', + ) + assert.equal(this.plugin.has_null_sender(this.connection), false) + }) + + it('user@example.com', () => { + this.connection.transaction.mail_from = new Address.Address( + 'user@example.com', + ) + assert.equal(this.plugin.has_null_sender(this.connection), false) + }) }) })