Commit message: SECURITY: Fix DoS on overly long input from Postfix

Thanks to Mateusz Jończyk who reported this issue and gave valuable
feedback for its resolution.

PostSRSd would hang on an overly long GET request, because the
fread()/fwrite() logic in the subprocess would get confused by the
remaining input line in its buffer.

Theoretically, this error should never occur, as Postfix is supposed to
send valid email addresses only, which are shorter than the buffer, even
assuming every single character is percent-encoded. However, Postfix
sometimes does seem to send malformed request with multiple concatenated
email addresses. I'm not sure if there's a reliable way to trigger this
condition by an external attacker, but it is a security bug in PostSRSd
nevertheless.
CVE: CVE-2021-35525
CWE: CWE-703
Vulnerability description: PostSRSd before 1.11 allows a denial of service (subprocess hang) if Postfix sends certain long data fields such as multiple concatenated email addresses. NOTE: the PostSRSd maintainer acknowledges "theoretically, this error should never occur ... I'm not sure if there's a reliable way to trigger this condition by an external attacker, but it is a security bug in PostSRSd nevertheless."

Project: postsrsd
File: postsrsd.c

int main(int argc, char **argv)
{
    int opt, timeout = 1800, family = AF_UNSPEC, hashlength = 0, hashmin = 0;
    int daemonize = FALSE, always_rewrite = FALSE;
    char *listen_addr = NULL, *forward_service = NULL, *reverse_service = NULL,
         *user = NULL, *domain = NULL, *chroot_dir = NULL;
    char separator = '=';
    char *secret_file = NULL, *pid_file = NULL;
    FILE *pf = NULL, *sf = NULL;
    struct passwd *pwd = NULL;
    char secretbuf[1024], *secret = NULL;
    char *tmp;
    time_t now;
    srs_t *srs;
    const char **excludes;
    size_t s1 = 0, s2 = 1;
    struct pollfd fds[4];
    size_t socket_count = 0, sc;
    int sockets[4] = {-1, -1, -1, -1};
    handle_t handler[4] = {0, 0, 0, 0};
    int fd, maxfd;

    excludes = (const char **)calloc(1, sizeof(char *));
    tmp = strrchr(argv[0], '/');
    if (tmp)
        self = strdup(tmp + 1);
    else
        self = strdup(argv[0]);

    while ((opt = getopt(argc, argv, "46d:a:l:f:r:s:n:N:u:t:p:c:X::ADhev"))
           != -1)
    {
        switch (opt)
        {
            case '?':
                return EXIT_FAILURE;
            case '4':
                family = AF_INET;
                break;
            case '6':
                family = AF_INET6;
                break;
            case 'd':
                domain = strdup(optarg);
                break;
            case 'a':
                separator = *optarg;
                break;
            case 'l':
                listen_addr = strdup(optarg);
                break;
            case 'f':
                forward_service = strdup(optarg);
                break;
            case 'r':
                reverse_service = strdup(optarg);
                break;
            case 't':
                timeout = atoi(optarg);
                break;
            case 's':
                secret_file = strdup(optarg);
                break;
            case 'n':
                hashlength = atoi(optarg);
                break;
            case 'N':
                hashmin = atoi(optarg);
                break;
            case 'p':
                pid_file = strdup(optarg);
                break;
            case 'u':
                user = strdup(optarg);
                break;
            case 'c':
                chroot_dir = strdup(optarg);
                break;
            case 'D':
                daemonize = TRUE;
                break;
            case 'A':
                always_rewrite = TRUE;
                break;
            case 'h':
                show_help();
                return EXIT_SUCCESS;
            case 'X':
                if (optarg != NULL)
                {
                    tmp = strtok(optarg, ",; \t\r\n");
                    while (tmp)
                    {
                        if (s1 + 1 >= s2)
                        {
                            s2 *= 2;
                            excludes = (const char **)realloc(
                                excludes, s2 * sizeof(char *));
                            if (excludes == NULL)
                            {
                                fprintf(stderr, "%s: Out of memory\n\n", self);
                                return EXIT_FAILURE;
                            }
                        }
                        excludes[s1++] = strdup(tmp);
                        tmp = strtok(NULL, ",; \t\r\n");
                    }
                    excludes[s1] = NULL;
                }
                break;
            case 'e':
                if (getenv("SRS_DOMAIN") != NULL)
                    domain = strdup(getenv("SRS_DOMAIN"));
                if (getenv("SRS_SEPARATOR") != NULL)
                    separator = *getenv("SRS_SEPARATOR");
                if (getenv("SRS_HASHLENGTH") != NULL)
                    hashlength = atoi(getenv("SRS_HASHLENGTH"));
                if (getenv("SRS_HASHMIN") != NULL)
                    hashmin = atoi(getenv("SRS_HASHMIN"));
                if (getenv("SRS_FORWARD_PORT") != NULL)
                    forward_service = strdup(getenv("SRS_FORWARD_PORT"));
                if (getenv("SRS_REVERSE_PORT") != NULL)
                    reverse_service = strdup(getenv("SRS_REVERSE_PORT"));
                if (getenv("SRS_TIMEOUT") != NULL)
                    timeout = atoi(getenv("SRS_TIMEOUT"));
                if (getenv("SRS_SECRET") != NULL)
                    secret_file = strdup(getenv("SRS_SECRET"));
                if (getenv("SRS_PID_FILE") != NULL)
                    pid_file = strdup(getenv("SRS_PID_FILE"));
                if (getenv("RUN_AS") != NULL)
                    user = strdup(getenv("RUN_AS"));
                if (getenv("CHROOT") != NULL)
                    chroot_dir = strdup(getenv("CHROOT"));
                if (getenv("SRS_EXCLUDE_DOMAINS") != NULL)
                {
                    tmp = strtok(getenv("SRS_EXCLUDE_DOMAINS"), ",; \t\r\n");
                    while (tmp)
                    {
                        if (s1 + 1 >= s2)
                        {
                            s2 *= 2;
                            excludes = (const char **)realloc(
                                excludes, s2 * sizeof(char *));
                            if (excludes == NULL)
                            {
                                fprintf(stderr, "%s: Out of memory\n\n", self);
                                return EXIT_FAILURE;
                            }
                        }
                        excludes[s1++] = strdup(tmp);
                        tmp = strtok(NULL, ",; \t\r\n");
                    }
                    excludes[s1] = NULL;
                }
                break;
            case 'v':
                fprintf(stdout, "%s\n", POSTSRSD_VERSION);
                return EXIT_SUCCESS;
        }
    }
    if (optind < argc)
    {
        fprintf(stderr, "%s: extra argument on command line: %s\n", self,
                argv[optind]);
        return EXIT_FAILURE;
    }
    if (domain == NULL || *domain == 0)
    {
        fprintf(stderr, "%s: You must set a home domain (-d)\n", self);
        return EXIT_FAILURE;
    }

    if (separator != '=' && separator != '+' && separator != '-')
    {
        fprintf(stderr, "%s: SRS separator character must be one of '=+-'\n",
                self);
        return EXIT_FAILURE;
    }
    if (forward_service == NULL)
        forward_service = strdup("10001");
    if (reverse_service == NULL)
        reverse_service = strdup("10002");

    /* Close all file descriptors (std ones will be closed later). */
    maxfd = sysconf(_SC_OPEN_MAX);
    for (fd = 3; fd < maxfd; fd++)
        close(fd);

    /* The stuff we do first may not be possible from within chroot or without
     * privileges */

    /* Open pid file for writing (the actual process ID is filled in later) */
    if (pid_file)
    {
        pf = fopen(pid_file, "w");
        if (pf == NULL)
        {
            fprintf(stderr, "%s: Cannot write PID: %s\n\n", self, pid_file);
            return EXIT_FAILURE;
        }
    }
    /* Read secret. The default installation makes this root accessible only. */
    if (secret_file != NULL)
    {
        sf = fopen(secret_file, "rb");
        if (sf == NULL)
        {
            fprintf(stderr, "%s: Cannot open file with secret: %s\n", self,
                    secret_file);
            return EXIT_FAILURE;
        }
    }
    else
    {
        fprintf(stderr, "%s: You must set a secret (-s)\n", self);
        return EXIT_FAILURE;
    }
    /* Bind ports. May require privileges if the config specifies ports below
     * 1024 */
    sc = bind_service(listen_addr, forward_service, family,
                      &sockets[socket_count], 4 - socket_count);
    if (sc == 0)
        return EXIT_FAILURE;
    while (sc-- > 0)
        handler[socket_count++] = handle_forward;
    free(forward_service);
    sc = bind_service(listen_addr, reverse_service, family,
                      &sockets[socket_count], 4 - socket_count);
    if (sc == 0)
        return EXIT_FAILURE;
    while (sc-- > 0)
        handler[socket_count++] = handle_reverse;
    free(reverse_service);
    /* Open syslog now (NDELAY), because it may no longer be reachable from
     * chroot */
    openlog(self, LOG_PID | LOG_NDELAY, LOG_MAIL);
    /* Force loading of timezone info (suggested by patrickdk77) */
    now = time(NULL);
    localtime(&now);
    /* We also have to lookup the uid of the unprivileged user before the
     * chroot. */
    if (user)
    {
        errno = 0;
        pwd = getpwnam(user);
        if (pwd == NULL)
        {
            if (errno != 0)
                fprintf(stderr, "%s: Failed to lookup user: %s\n", self,
                        strerror(errno));
            else
                fprintf(stderr, "%s: No such user: %s\n", self, user);
            return EXIT_FAILURE;
        }
    }
    /* Now we can chroot, which again requires root privileges */
    if (chroot_dir)
    {
        if (chdir(chroot_dir) < 0)
        {
            fprintf(stderr, "%s: Cannot change to chroot: %s\n", self,
                    strerror(errno));
            return EXIT_FAILURE;
        }
        if (chroot(chroot_dir) < 0)
        {
            fprintf(stderr, "%s: Failed to enable chroot: %s\n", self,
                    strerror(errno));
            return EXIT_FAILURE;
        }
    }
    /* Finally, we revert to the unprivileged user */
    if (pwd)
    {
        if (setgid(pwd->pw_gid) < 0)
        {
            fprintf(stderr, "%s: Failed to switch group id: %s\n", self,
                    strerror(errno));
            return EXIT_FAILURE;
        }
        if (setuid(pwd->pw_uid) < 0)
        {
            fprintf(stderr, "%s: Failed to switch user id: %s\n", self,
                    strerror(errno));
            return EXIT_FAILURE;
        }
    }
    /* Standard double fork technique to disavow all knowledge about the
     * controlling terminal */
    if (daemonize)
    {
        close(0);
        close(1);
        close(2);
        if (fork() != 0)
            return EXIT_SUCCESS;
        setsid();
        if (fork() != 0)
            return EXIT_SUCCESS;
    }
    /* Make note of our actual process ID */
    if (pf)
    {
        fprintf(pf, "%d", (int)getpid());
        fclose(pf);
    }

    srs = srs_new();
    while ((secret = fgets(secretbuf, sizeof(secretbuf), sf)))
    {
        secret = strtok(secret, "\r\n");
        if (secret)
            srs_add_secret(srs, secret);
    }
    fclose(sf);

    srs_set_alwaysrewrite(srs, always_rewrite);
    srs_set_separator(srs, separator);
    if (hashlength)
        srs_set_hashlength(srs, hashlength);
    if (hashmin)
        srs_set_hashmin(srs, hashmin);

    for (sc = 0; sc < socket_count; ++sc)
    {
        fds[sc].fd = sockets[sc];
        fds[sc].events = POLLIN;
    }
    while (TRUE)
    {
        int conn;
        FILE *fp;
        char linebuf[1024], *line;
        char keybuf[1024], *key;

        if (poll(fds, socket_count, 1000) < 0)
        {
            if (errno == EINTR)
                continue;
            if (daemonize)
                syslog(LOG_MAIL | LOG_ERR, "Poll failure: %s", strerror(errno));
            else
                fprintf(stderr, "%s: Poll failure: %s\n", self,
                        strerror(errno));
            return EXIT_FAILURE;
        }
        for (sc = 0; sc < socket_count; ++sc)
        {
            if (fds[sc].revents)
            {
                conn = accept(fds[sc].fd, NULL, NULL);
                if (conn < 0)
                    continue;
                if (fork() == 0)
                {
                    int i;
                    /* close listen sockets so that we don't stop the main
                     * daemon process from restarting */
                    for (i = 0; i < socket_count; ++i)
                        close(sockets[i]);

                    fp = fdopen(conn, "r+");
                    if (fp == NULL)
                        exit(EXIT_FAILURE);
                    fds[0].fd = conn;
                    fds[0].events = POLLIN;
                    if (poll(fds, 1, timeout * 1000) <= 0)
                        return EXIT_FAILURE;
                    line = fgets(linebuf, sizeof(linebuf), fp);
                    while (line)
                    {
                        fseek(fp, 0, SEEK_CUR); /* Workaround for Solaris */
                        char *token;
                        token = strtok(line, " \r\n");
                        if (token == NULL || strcmp(token, "get") != 0)
                        {
                            fprintf(fp, "500 Invalid request\n");
                            fflush(fp);
                            return EXIT_FAILURE;
                        }
                        token = strtok(NULL, "\r\n");
                        if (!token)
                        {
                            fprintf(fp, "500 Invalid request\n");
                            fflush(fp);
                            return EXIT_FAILURE;
                        }
                        key = url_decode(keybuf, sizeof(keybuf), token);
                        if (!key)
                        {
                            fprintf(fp, "500 Invalid request\n");
                            fflush(fp);
                            return EXIT_FAILURE;
                        }
                        handler[sc](srs, fp, key, domain, excludes);
                        fflush(fp);
                        if (poll(fds, 1, timeout * 1000) <= 0)
                            break;
                        line = fgets(linebuf, sizeof(linebuf), fp);
                    }
                    fclose(fp);
                    return EXIT_SUCCESS;
                }
                close(conn);
            }
        }
        waitpid(-1, NULL, WNOHANG);
    }
    return EXIT_SUCCESS;
}