JDBCAppender.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.log4j.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;

import org.apache.log4j.PatternLayout;
import org.apache.log4j.helpers.LogLog;
import org.apache.log4j.spi.ErrorCode;
import org.apache.log4j.spi.LoggingEvent;

/**
 * The JDBCAppender provides for sending log events to a database. It does not log exceptions.
 *
 * <p><b>JDBCAppender shipping in 1.2.18.2 produces
 * {@link java.sql.PreparedStatement PreparedStatement} instances</b>. Thanks to the remarkable work of Vladimir
 * Sitnikov JDBCAppender now interprets the SQL expression on the fly and inserts new events using PreparedStartement
 * instances. Note that the table column types are restricted to those types compatible with Java's String.
 * </p>
 *
 * <p><font color="#FF2222" style="font-weight: bold;">Versions 1.2.18.0 and prior
 * are vulnerable to SQL injection attacks.</b></font>.
 * </p>
 *
 * <p>
 * Each append call adds to an {@link ArrayList} buffer. When the buffer is filled each log event is placed in a sql
 * statement (configurable) and executed.
 * </p>
 *
 * <p><b>BufferSize</b>, <b>db URL</b>, <b>User</b>, &amp; <b>Password</b> are
 * configurable options in the standard log4j fashion.
 * </p>
 *
 * <p>
 * The <code>setSql(String sql)</code> sets the SQL statement to be used for logging -- this statement is sent to a
 * <code>PatternLayout</code> (either created automatically by the appender or added by the user). Therefore by default
 * all the conversion patterns in <code>PatternLayout</code> can be used inside of the statement. (see the test cases
 * for examples).
 * </p>
 * <p>As mentioned earlier, the produced
 * SQL is translated in an additional phase to use {@link java.sql.PreparedStatement PreparedStatement}.
 * </p>
 *
 *
 * <p>
 * For use as a base class:
 *
 * <ul>
 *
 * <li>Override <code>getConnection()</code> to pass any connection you want.
 * Typically this is used to enable application wide connection pooling.
 *
 * <li>Override <code>closeConnection(Connection con)</code> -- if you override
 * getConnection make sure to implement <code>closeConnection</code> to handle
 * the connection you generated. Typically this would return the connection to
 * the pool it came from.
 *
 * <li>As of 1.2.18.1 {@link #getLogStatement} is no longer in use.
 *
 * </ul>
 *
 * @author Kevin Steppe (<A HREF="mailto:ksteppe@pacbell.net">ksteppe@pacbell.net</A>)
 */
public class JDBCAppender extends org.apache.log4j.AppenderSkeleton implements org.apache.log4j.Appender {

    //    private final Boolean secureSqlReplacement = Boolean
    //	    .parseBoolean(System.getProperty("org.apache.log4j.jdbc.JDBCAppender.secure_jdbc_replacement", "true"));

    private static final IllegalArgumentException ILLEGAL_PATTERN_FOR_SECURE_EXEC = new IllegalArgumentException(
            "Only org.apache.log4j.PatternLayout is supported for now due to CVE-2022-23305");

    /**
     * URL of the DB for default connection handling
     */
    protected String databaseURL = "jdbc:odbc:myDB";

    /**
     * User to connect as for default connection handling
     */
    protected String databaseUser = "me";

    /**
     * User to use for default connection handling
     */
    protected String databasePassword = "mypassword";

    /**
     * Connection used by default. The connection is opened the first time it is needed and then held open until the
     * appender is closed (usually at garbage collection). This behavior is best modified by creating a sub-class and
     * overriding the <code>getConnection</code> and <code>closeConnection</code> methods.
     */
    protected Connection connection = null;

    /**
     * Stores the string given to the pattern layout for conversion into a SQL statement, eg: insert into LogTable
     * (Thread, Class, Message) values ("%t", "%c", "%m").
     * <p>
     * Be careful of quotes in your messages!
     * <p>
     * Also see PatternLayout.
     */
    protected String sqlStatement = "";

    private JdbcPatternParser preparedStatementParser;
    /**
     * size of LoggingEvent buffer before writting to the database. Default is 1.
     */
    protected int bufferSize = 1;

    /**
     * ArrayList holding the buffer of Logging Events.
     */
    protected ArrayList<LoggingEvent> buffer;

    private boolean locationInfo = false;

    private boolean isActive = false;

    public JDBCAppender() {
        super();
        buffer = new ArrayList<LoggingEvent>(bufferSize);
    }

    @Override
    public void activateOptions() {

        if (getSql() == null || getSql().trim().length() == 0) {
            LogLog.error("JDBCAppender.sql parameter is mandatory. Skipping all futher processing");
            isActive = false;
            return;
        }

        LogLog.debug("JDBCAppender constructing internal pattern parser");
        preparedStatementParser = new JdbcPatternParser(getSql());
        isActive = true;
    }

    /**
     * Gets whether the location of the logging request call should be captured.
     *
     * @return the current value of the <b>LocationInfo</b> option.
     * @since 1.2.16
     */
    public boolean getLocationInfo() {
        return locationInfo;
    }

    /**
     * The <b>LocationInfo</b> option takes a boolean value. By default, it is set to false which means there will be no
     * effort to extract the location information related to the event. As a result, the event that will be ultimately
     * logged will likely to contain the wrong location information (if present in the log format).
     * <p/>
     * <p/>
     * Location information extraction is comparatively very slow and should be avoided unless performance is not a
     * concern.
     * </p>
     *
     * @param flag true if location information should be extracted.
     * @since 1.2.16
     */
    public void setLocationInfo(final boolean flag) {
        locationInfo = flag;
    }

    /**
     * Adds the event to the buffer. When full the buffer is flushed.
     */
    public void append(LoggingEvent event) {
        if (!isActive) {
            return;
        }
        event.getNDC();
        event.getThreadName();
        // Get a copy of this thread's MDC.
        event.getMDCCopy();
        if (locationInfo) {
            event.getLocationInformation();
        }
        event.getRenderedMessage();
        event.getThrowableStrRep();
        buffer.add(event);

        if (buffer.size() >= bufferSize)
            flushBuffer();
    }

    /**
     * <p>
     * <b>As of 1.2.18.1 {@link #getLogStatement} is no longer in use.</b>
     * </p>
     *
     * @param event
     */
    protected String getLogStatement(LoggingEvent event) {
        return "";
    }

    /**
     * Override this to provide an alertnate method of getting connections (such as caching). One method to fix this is
     * to open connections at the start of flushBuffer() and close them at the end. I use a connection pool outside of
     * JDBCAppender which is accessed in an override of this method.
     */
    protected void execute(String sql) throws SQLException {

        Connection con = null;
        Statement stmt = null;

        try {
            con = getConnection();

            stmt = con.createStatement();
            stmt.executeUpdate(sql);
        } finally {
            if (stmt != null) {
                stmt.close();
            }
            closeConnection(con);
        }

        // System.out.println("Execute: " + sql);
    }

    /**
     * Override this to return the connection to a pool, or to clean up the resource.
     * <p>
     * The default behavior holds a single connection open until the appender is closed (typically when garbage
     * collected).
     */
    protected void closeConnection(Connection con) {
    }

    /**
     * Override this to link with your connection pooling system.
     * <p>
     * By default this creates a single connection which is held open until the object is garbage collected.
     */
    protected Connection getConnection() throws SQLException {
        if (!DriverManager.getDrivers().hasMoreElements())
            setDriver("sun.jdbc.odbc.JdbcOdbcDriver");

        if (connection == null) {
            connection = DriverManager.getConnection(databaseURL, databaseUser, databasePassword);
        }

        return connection;
    }

    /**
     * Closes the appender, flushing the buffer first then closing the default connection if it is open.
     */
    public void close() {
        flushBuffer();

        try {
            if (connection != null && !connection.isClosed())
                connection.close();
        } catch (SQLException e) {
            errorHandler.error("Error closing connection", e, ErrorCode.GENERIC_FAILURE);
        }
        this.closed = true;
    }

    /**
     * loops through the buffer of LoggingEvents, gets a sql string from getLogStatement() and sends it to execute().
     * Errors are sent to the errorHandler.
     * <p>
     * If a statement fails the LoggingEvent stays in the buffer!
     */
    public void flushBuffer() {
        if (buffer.isEmpty()) {
            return;
        }
        flushBufferSecure();
    }

    private void flushBufferSecure() {
        // Prepare events that we will store to the DB
        ArrayList<LoggingEvent> removes = new ArrayList<LoggingEvent>(buffer);
        buffer.removeAll(removes);

        if (layout.getClass() != PatternLayout.class) {
            errorHandler.error("Failed to convert pattern " + layout + " to SQL", ILLEGAL_PATTERN_FOR_SECURE_EXEC,
                    ErrorCode.MISSING_LAYOUT);
            return;
        }
        Connection con = null;
        boolean useBatch = removes.size() > 1;
        try {
            con = getConnection();
            PreparedStatement ps = con.prepareStatement(preparedStatementParser.getParameterizedSql());
            safelyInsertIntoDB(removes, useBatch, ps);
        } catch (SQLException e) {
            errorHandler.error("Failed to append messages sql", e, ErrorCode.FLUSH_FAILURE);
        } finally {
            if (con != null) {
                closeConnection(con);
            }
        }

    }

    private void safelyInsertIntoDB(ArrayList<LoggingEvent> removes, boolean useBatch, PreparedStatement ps)
            throws SQLException {
        try {
            for (LoggingEvent logEvent : removes) {
                try {
                    preparedStatementParser.setParameters(ps, logEvent);
                    if (useBatch) {
                        ps.addBatch();
                    }
                } catch (SQLException e) {
                    errorHandler.error("Failed to append parameters", e, ErrorCode.FLUSH_FAILURE);
                }
            }
            if (useBatch) {
                ps.executeBatch();
            } else {
                ps.execute();
            }
        } finally {
            try {
                ps.close();
            } catch (SQLException ignored) {
            }
        }
    }

    /**
     * closes the appender before disposal
     */
    public void finalize() {
        close();
    }

    /**
     * JDBCAppender requires a layout.
     */
    public boolean requiresLayout() {
        return true;
    }

    /**
     *
     */
    public void setSql(String s) {
        sqlStatement = s;
        if (getLayout() == null) {
            this.setLayout(new PatternLayout(s));
        } else {
            ((PatternLayout) getLayout()).setConversionPattern(s);
        }
    }

    /**
     * Returns pre-formated statement eg: insert into LogTable (msg) values ("%m")
     */
    public String getSql() {
        return sqlStatement;
    }

    public void setUser(String user) {
        databaseUser = user;
    }

    public void setURL(String url) {
        databaseURL = url;
    }

    public void setPassword(String password) {
        databasePassword = password;
    }

    public void setBufferSize(int newBufferSize) {
        bufferSize = newBufferSize;
        buffer.ensureCapacity(bufferSize);
    }

    public String getUser() {
        return databaseUser;
    }

    public String getURL() {
        return databaseURL;
    }

    public String getPassword() {
        return databasePassword;
    }

    public int getBufferSize() {
        return bufferSize;
    }

    /**
     * Ensures that the given driver class has been loaded for sql connection creation.
     */
    public void setDriver(String driverClass) {
        try {
            Class.forName(driverClass);
        } catch (Exception e) {
            errorHandler.error("Failed to load driver", e, ErrorCode.GENERIC_FAILURE);
        }
    }
}