/* This code is part of Freenet. It is distributed under the GNU General
* Public License, version 2 (or at your option any later version). See
* http://www.gnu.org/ for further details of the GPL. */
package freenet.client.filter;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.util.Arrays;
import java.util.HashMap;
import java.util.zip.CRC32;
import freenet.l10n.NodeL10n;
import freenet.support.HexUtil;
import freenet.support.LogThresholdCallback;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.api.Bucket;
import freenet.support.io.Closer;
import freenet.support.io.FileBucket;
/**
* Content filter for PNG's. Only allows valid chunks (valid CRC, known chunk type).
*
* It can strip the timestamp and "text(.)*" chunks if asked to
*
* FIXME: validate chunk contents where possible.
*/
public class PNGFilter implements ContentDataFilter {
private final boolean deleteText;
private final boolean deleteTimestamp;
private final boolean checkCRCs;
static final byte[] pngHeader = { (byte) 137, (byte) 80, (byte) 78, (byte) 71, (byte) 13, (byte) 10, (byte) 26,
(byte) 10 };
static final String[] HARMLESS_CHUNK_TYPES = {
// http://www.w3.org/TR/PNG/
"tRNS", "cHRM", "gAMA", "iCCP", // FIXME Embedded ICC profile: could this conceivably cause a web lookup?
"sBIT", // FIXME rather obscure ??
"sRGB", "bKGD", "hIST", "pHYs", "sPLT",
// APNG chunks (Firefox 3 will support APNG)
// http://wiki.mozilla.org/APNG_Specification
"acTL", "fcTL", "fdAT"
// MNG isn't supported by Firefox and IE because of lack of market demand. Big surprise
// given nobody supports it! It is supported by Konqueror though. Complex standard,
// not worth it for the time being.
// This might be a useful source of info too (e.g. on private chunks):
// http://fresh.t-systems-sfr.com/unix/privat/pngcheck-2.3.0.tar.gz:a/pngcheck-2.3.0/pngcheck.c
};
private static volatile boolean logMINOR;
private static volatile boolean logDEBUG;
static {
Logger.registerLogThresholdCallback(new LogThresholdCallback(){
@Override
public void shouldUpdate(){
logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
logDEBUG = Logger.shouldLog(LogLevel.DEBUG, this);
}
});
}
PNGFilter(boolean deleteText, boolean deleteTimestamp, boolean checkCRCs) {
this.deleteText = deleteText;
this.deleteTimestamp = deleteTimestamp;
this.checkCRCs = checkCRCs;
}
@Override
public void readFilter(InputStream input, OutputStream output, String charset, HashMap<String, String> otherParams,
FilterCallback cb) throws DataFilterException, IOException {
readFilter(input, output, charset, otherParams, cb, deleteText, deleteTimestamp, checkCRCs);
output.flush();
}
public void readFilter(InputStream input, OutputStream output, String charset, HashMap<String, String> otherParams,
FilterCallback cb, boolean deleteText, boolean deleteTimestamp, boolean checkCRCs)
throws DataFilterException, IOException {
DataInputStream dis = null;
boolean hasSeenIHDR = false;
boolean hasSeenIEND = false;
boolean hasSeenIDAT = false;
try {
long offset = 0;
dis = new DataInputStream(input);
// Check the header
byte[] headerCheck = new byte[pngHeader.length];
dis.readFully(headerCheck);
offset+=pngHeader.length;
if (!Arrays.equals(headerCheck, pngHeader)) {
// Throw an exception
String message = l10n("invalidHeader");
String title = l10n("invalidHeaderTitle");
throw new DataFilterException(title, title, message);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
output.write(pngHeader);
if (logMINOR)
Logger.minor(this, "Writing the PNG header to the output bucket");
// Check the chunks :
// @see http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html#C.Summary-of-standard-chunks
String lastChunkType = "";
while (!hasSeenIEND) {
boolean skip = false;
baos.reset();
String chunkTypeString = null;
// Length of the chunk
byte[] lengthBytes = new byte[4];
try {
dis.readFully(lengthBytes);
offset+=4;
} catch (EOFException e) {
// FIXME optimise out the throw, don't use readFully?
// This will happen once per filtering.
// We can't use available() for reasons explained in
// the javadocs for ContentDataFilter.
break;
}
int length = ((lengthBytes[0] & 0xff) << 24) + ((lengthBytes[1] & 0xff) << 16)
+ ((lengthBytes[2] & 0xff) << 8) + (lengthBytes[3] & 0xff);
if (logMINOR)
Logger.minor(this, "length " + length+ "(offset=0x"+Long.toHexString(offset)+") ");
if (dos != null)
dos.write(lengthBytes);
// Type of the chunk : Should match [a-zA-Z]{4}
dis.readFully(lengthBytes);
offset+=4;
StringBuilder sb = new StringBuilder();
byte[] chunkTypeBytes = new byte[4];
for (int i = 0; i < 4; i++) {
char val = (char) lengthBytes[i];
if ((val >= 65 && val <= 99) || (val >= 97 && val <= 122)) {
chunkTypeBytes[i] = lengthBytes[i];
sb.append(val);
} else {
String chunkName = HexUtil.bytesToHex(lengthBytes, 0, 4);
throwError("Unknown Chunk", "The name of the chunk is invalid! (" + chunkName + ")");
}
}
chunkTypeString = sb.toString();
if (logMINOR)
Logger.minor(this, "name " + chunkTypeString);
if (dos != null)
dos.write(chunkTypeBytes);
// Content of the chunk
byte[] chunkData = new byte[length];
if(length > 0) {
dis.readFully(chunkData, 0, length);
offset+=length;
if (logMINOR)
if (logDEBUG)
Logger.minor(this, "data (offset=0x"+Long.toHexString(offset)+") "+ (chunkData.length == 0 ? "null" : HexUtil.bytesToHex(chunkData)));
else
Logger.minor(this, "data " + chunkData.length);
if (dos != null)
dos.write(chunkData);
}
// CRC of the chunk
byte[] crcLengthBytes = new byte[4];
dis.readFully(crcLengthBytes);
offset+=4;
if(logMINOR) Logger.minor(this, "CRC offset=0x"+Long.toHexString(offset));
if (dos != null)
dos.write(crcLengthBytes);
if (checkCRCs) {
long readCRC = (((crcLengthBytes[0] & 0xff) << 24) + ((crcLengthBytes[1] & 0xff) << 16)
+ ((crcLengthBytes[2] & 0xff) << 8) + (crcLengthBytes[3] & 0xff)) & 0x00000000ffffffffL;
CRC32 crc = new CRC32();
crc.update(chunkTypeBytes);
if(length > 0)
crc.update(chunkData);
long computedCRC = crc.getValue();
if (readCRC != computedCRC) {
skip = true;
if (logMINOR)
Logger.minor(this, "CRC of the chunk " + chunkTypeString + " doesn't match ("
+ Long.toHexString(readCRC) + " but should be " + Long.toHexString(computedCRC)
+ ")!");
}
}
boolean validChunkType = false;
if (!skip && "IHDR".equals(chunkTypeString)) { // http://www.w3.org/TR/PNG/#11IHDR
if (hasSeenIHDR)
throwError("Duplicate IHDR", "Two IHDR chunks detected!!");
if(length != 13)
throwError("IHDR length!= 13", "The length of the IHDR file is not 13");
long width = ((chunkData[0] & 0xff) << 24) + ((chunkData[1] & 0xff) << 16) + ((chunkData[2] & 0xff) << 8) + (chunkData[3] & 0xff);
long height = ((chunkData[4] & 0xff) << 24) + ((chunkData[5] & 0xff) << 16) + ((chunkData[6] & 0xff) << 8) + (chunkData[7] & 0xff);
if(width < 1 || height < 1)
throwError("Width or Height is invalid", "Width or Height is invalid (<1)");
int bitDepth = chunkData[8];
int colourType = chunkData[9];
switch (bitDepth) {
case 1:
case 2:
case 4:
if(colourType != 0 && colourType != 3)
throwError("Invalid colourType/bitDepth combination!",
"Invalid colourType/bitDepth combination! ("+colourType+'|'+bitDepth+')');
break;
case 16:
if(colourType == 3)
throwError("Invalid colourType/bitDepth combination!",
"Invalid colourType/bitDepth combination! ("+colourType+'|'+bitDepth+')');
case 8:
if(colourType == 0 || colourType ==2 || colourType ==3 || colourType ==4|| colourType ==6)
break;
default: throwError("Invalid colourType/bitDepth combination!",
"Invalid colourType/bitDepth combination! ("+colourType+'|'+bitDepth+')');
}
int compressionMethod = chunkData[10];
if(compressionMethod != 0)
throwError("Invalid CompressionMethod", "Invalid CompressionMethod! "+compressionMethod);
int filterMethod = chunkData[11];
if(filterMethod != 0)
throwError("Invalid FilterMethod", "Invalid FilterMethod! "+filterMethod);
int interlaceMethod = chunkData[12];
if(interlaceMethod < 0 || interlaceMethod >1)
throwError("Invalid InterlaceMethod", "Invalid InterlaceMethod! "+interlaceMethod);
if(logMINOR)
Logger.minor(this, "Info from IHDR: width="+width+"px height="+height+"px bitDepth="+bitDepth+
" colourType="+colourType+" compressionMethod="+compressionMethod+" filterMethod="+
filterMethod+" interlaceMethod="+interlaceMethod);
hasSeenIHDR = true;
validChunkType = true;
}
if (!hasSeenIHDR)
throwError("No IHDR chunk!", "No IHDR chunk!");
if (!skip && "IEND".equals(chunkTypeString)) {
if (hasSeenIEND)
throwError("Two IEND chunks detected!!", "Two IEND chunks detected!!");
hasSeenIEND = true;
validChunkType = true;
}
if (!skip && "PLTE".equalsIgnoreCase(chunkTypeString)) {
if (hasSeenIDAT)
throwError("PLTE must be before IDAT", "PLTE must be before IDAT");
validChunkType = true;
}
if (!skip && "IDAT".equalsIgnoreCase(chunkTypeString)) {
if (hasSeenIDAT && !"IDAT".equalsIgnoreCase(lastChunkType))
throwError("Multiple IDAT chunks must be consecutive!",
"Multiple IDAT chunks must be consecutive!");
hasSeenIDAT = true;
validChunkType = true;
}
if (!validChunkType) {
for (int i = 0; i < HARMLESS_CHUNK_TYPES.length; i++) {
if (HARMLESS_CHUNK_TYPES[i].equals(chunkTypeString))
validChunkType = true;
}
}
if ("text".equalsIgnoreCase(chunkTypeString) || "itxt".equalsIgnoreCase(chunkTypeString)
|| "ztxt".equalsIgnoreCase(chunkTypeString)) {
if (deleteText)
skip = true;
else
validChunkType = true;
} else if (deleteTimestamp && "time".equalsIgnoreCase(chunkTypeString)) {
if (deleteTimestamp)
skip = true;
else
validChunkType = true;
}
if (!validChunkType) {
if (logMINOR)
Logger.minor(this, "Skipping unknown chunk type " + chunkTypeString);
skip = true;
}
else if (!skip && output != null) {
if (logMINOR)
Logger
.minor(this, "Writing " + chunkTypeString + " (" + baos.size()
+ ") to the output bucket");
baos.writeTo(output);
baos.flush();
}
lastChunkType = chunkTypeString;
}
if (!hasSeenIEND)
throwError("Missing IEND", "Missing IEND");
if (!hasSeenIHDR)
throwError("Missing IHDR", "Missing IHDR");
return; // Strip everything after IEND.
} catch (ArrayIndexOutOfBoundsException e) {
throwError("ArrayIndexOutOfBoundsException while filtering", "ArrayIndexOutOfBoundsException while filtering");
} catch (NegativeArraySizeException e) {
throwError("NegativeArraySizeException while filtering", "NegativeArraySizeException while filtering");
} catch (EOFException e) {
if(hasSeenIEND && hasSeenIHDR) return;
throwError("EOF Exception while filtering", "EOF Exception while filtering");
}
}
private String l10n(String key) {
return NodeL10n.getBase().getString("PNGFilter." + key);
}
@Override
public void writeFilter(InputStream input, OutputStream output, String charset, HashMap<String, String> otherParams,
FilterCallback cb) throws DataFilterException, IOException {
// TODO Auto-generated method stub
return;
}
public static void main(String arg[]) throws Throwable {
final File fin = new File("/tmp/test.png");
final File fout = new File("/tmp/test2.png");
fout.delete();
final Bucket inputBucket = new FileBucket(fin, true, false, false, false);
final Bucket outputBucket = new FileBucket(fout, false, true, false, false);
InputStream inputStream = null;
OutputStream outputStream = null;
try {
inputStream = inputBucket.getInputStream();
outputStream = outputBucket.getOutputStream();
Logger.setupStdoutLogging(LogLevel.MINOR, "");
ContentFilter.filter(inputStream, outputStream, "image/png",
new URI("http://127.0.0.1:8888/"), null, null, null);
} finally {
Closer.close(inputStream);
Closer.close(outputStream);
inputBucket.free();
outputBucket.free();
}
}
private void throwError(String shortReason, String reason) throws DataFilterException {
// Throw an exception
String message = "Invalid PNG";
if (reason != null)
message += ' ' + reason;
if (shortReason != null)
message += " - " + shortReason;
DataFilterException e = new DataFilterException(shortReason, shortReason, message);
Logger.normal(this, "Throwing " + e.getMessage(), e);
throw e;
}
}