postalsys / mailauth

Command line utility and a Node.js library for email authentication
Other
126 stars 10 forks source link

DOS when performing DKIM-validation of email #64

Closed valeriansaliou closed 3 weeks ago

valeriansaliou commented 3 weeks ago

Hello!

Encountered CPU stuck at 100% on all our inbound email microservices fleet this night and morning, for a repeated number of times after manual NodeJS process restarts probably due to SMTP delivery retries.

The issue seems to be a DOS, which was traced to origin in mailauth DKIM module after performing a Node profile (--prof), here's the trace extract from the processed profile:

   ticks parent  name
  813252   88.5%  /usr/lib/x86_64-linux-gnu/libc.so.6
  755301   92.9%    JS: *fixLineBuffer /app/node_modules/mailauth/lib/dkim/body/relaxed.js:125:18
  755299  100.0%      JS: *update /app/node_modules/mailauth/lib/dkim/body/relaxed.js:169:11
  755297  100.0%        JS: *processChunk /app/node_modules/mailauth/lib/dkim/message-parser.js:40:23
  755291  100.0%          JS: ^writeAsync /app/node_modules/mailauth/lib/dkim/message-parser.js:112:21
  755291  100.0%            JS: ^_write /app/node_modules/mailauth/lib/dkim/message-parser.js:128:11

  96000   10.4%  UNKNOWN
  60769   63.3%    JS: *fixLineBuffer /app/node_modules/mailauth/lib/dkim/body/relaxed.js:125:18
  60761  100.0%      JS: *update /app/node_modules/mailauth/lib/dkim/body/relaxed.js:169:11
  60751  100.0%        JS: *processChunk /app/node_modules/mailauth/lib/dkim/message-parser.js:40:23
  60718   99.9%          JS: ^writeAsync /app/node_modules/mailauth/lib/dkim/message-parser.js:112:21
  60718  100.0%            JS: ^_write /app/node_modules/mailauth/lib/dkim/message-parser.js:128:11
   1144    1.2%    JS: *isSignature /app/node_modules/email-reply-parser/lib/parser/emailparser.js:143:14
   1144  100.0%      JS: ^<anonymous> /app/node_modules/email-reply-parser/lib/parser/emailparser.js:43:50
   1144  100.0%        JS: ^parse /app/node_modules/email-reply-parser/lib/parser/emailparser.js:37:8
   1144  100.0%          JS: ^read /app/node_modules/email-reply-parser/lib/emailreplyparser.js:4:9
   1144  100.0%            JS: ^<anonymous> /app/src/helpers/parser.js:1491:15
   1083    1.1%    JS: ^createPublicKey node:internal/crypto/keys:611:25
   1082   99.9%      JS: ^getPublicKey /app/node_modules/mailauth/lib/tools.js:237:22
    841   77.7%        JS: *processTicksAndRejections node:internal/process/task_queues:67:35
    241   22.3%        JS: ^processTicksAndRejections node:internal/process/task_queues:67:35

When tracing the code, there appears to be some O(n^2) algorithm involved here (no Regex, this does not appear to be a ReDOS to me): https://github.com/postalsys/mailauth/blob/master/lib/dkim/body/relaxed.js#L212

I unfortunately do not have the original mail data which triggered this possible DOS vulnerability, since we do not keep a record of emails that went through before processing them. I'm trying to obtain the email content now.

baptistejamin commented 3 weeks ago
const { performance } = require('perf_hooks');

// Constants
const CHAR_CR = 0x0d;
const CHAR_LF = 0x0a;
const CHAR_SPACE = 0x20;
const CHAR_TAB = 0x09;

function patchedFixLineBuffer(line) {
    // Allocate max possible size
    const resultLine = Buffer.alloc(line.length * 2);
    let writeIndex = resultLine.length - 1;
    let nonWspFound = false;
    let prevWsp = false;

    for (let i = line.length - 1; i >= 0; i--) {
        if (line[i] === CHAR_LF) {
            resultLine[writeIndex--] = CHAR_LF;
            if (i === 0 || line[i - 1] !== CHAR_CR) {
                resultLine[writeIndex--] = CHAR_CR;
            }
            continue;
        }
        if (line[i] === CHAR_CR) {
            resultLine[writeIndex--] = CHAR_CR;
            continue;
        }
        if (line[i] === CHAR_SPACE || line[i] === CHAR_TAB) {
            if (nonWspFound) {
                prevWsp = true;
            }
            continue;
        }
        if (prevWsp) {
            resultLine[writeIndex--] = CHAR_SPACE;
            prevWsp = false;
        }
        nonWspFound = true;
        resultLine[writeIndex--] = line[i];
    }

    if (prevWsp && nonWspFound) {
        resultLine[writeIndex--] = CHAR_SPACE;
    }

    return resultLine.slice(writeIndex + 1);
}

// The vulnerable fixLineBuffer function
function fixLineBuffer(line) {
    let resultLine = [];
    let nonWspFound = false;
    let prevWsp = false;
    for (let i = line.length - 1; i >= 0; i--) {
        if (line[i] === CHAR_LF) {
            resultLine.unshift(line[i]);
            if (i === 0 || line[i - 1] !== CHAR_CR) {
                resultLine.unshift(CHAR_CR);
            }
            continue;
        }
        if (line[i] === CHAR_CR) {
            resultLine.unshift(line[i]);
            continue;
        }
        if (line[i] === CHAR_SPACE || line[i] === CHAR_TAB) {
            if (nonWspFound) {
                prevWsp = true;
            }
            continue;
        }
        if (prevWsp) {
            resultLine.unshift(CHAR_SPACE);
            prevWsp = false;
        }
        nonWspFound = true;
        resultLine.unshift(line[i]);
    }
    if (prevWsp && nonWspFound) {
        resultLine.unshift(CHAR_SPACE);
    }
    return Buffer.from(resultLine);
}

// Function to generate a test line
function generateTestLine(length) {
    let line = Buffer.alloc(length);
    for (let i = 0; i < length; i++) {
        line[i] = i % 2 === 0 ? CHAR_SPACE : 97 + (i % 26); // alternating space and lowercase letters
    }
    return line;
}

// Function to run the test
function runTest(length) {
    const testLine = generateTestLine(length);

    let start = performance.now();
    patchedFixLineBuffer(testLine);
    let end = performance.now();

    console.log(`PATCHED Line length: ${length}, Time taken: ${(end - start).toFixed(2)} ms`);

    start = performance.now();
    fixLineBuffer(testLine);
    end = performance.now();

    console.log(`CURRENT Line length: ${length}, Time taken: ${(end - start).toFixed(2)} ms`);
}

// Run tests with increasing line lengths
console.log("Starting fixLineBuffer vulnerability test...");
for (let length = 1000; length <= 1000000; length *= 10) {
    runTest(length);
}

// Test with an extremely long line to demonstrate potential DoS
console.log("\nTesting with an extremely long line:");
runTest(10000000); // 10 million characters

Here is a script that reproduce the problem

andris9 commented 3 weeks ago

Fixed in https://github.com/postalsys/mailauth/releases/tag/v4.6.9