blob: 9b4f7075fdc9fe90c7eeb6b2bc818255ba09310b [file] [log] [blame]
/****************************************************************
* 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.james.jspf.core;
/**
* This Class is used to convert all macros which can used in SPF-Records to the
* right values!
*
*/
import org.apache.james.jspf.core.exceptions.NeutralException;
import org.apache.james.jspf.core.exceptions.NoneException;
import org.apache.james.jspf.core.exceptions.PermErrorException;
import org.apache.james.jspf.core.exceptions.TempErrorException;
import org.apache.james.jspf.core.exceptions.TimeoutException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MacroExpand {
private Pattern domainSpecPattern;
private Pattern macroStringPattern;
private Pattern macroLettersPattern;
private Pattern macroLettersExpPattern;
private Pattern cellPattern;
private Logger log;
private DNSService dnsProbe;
public static final boolean EXPLANATION = true;
public static final boolean DOMAIN = false;
public static class RequireClientDomainException extends Exception {
private static final long serialVersionUID = 3834282981657676530L;
}
/**
* Construct MacroExpand
*
* @param logger the logget to use
* @param dnsProbe the dns service to use
*/
public MacroExpand(Logger logger, DNSService dnsProbe) {
// This matches 2 groups
domainSpecPattern = Pattern.compile(SPFTermsRegexps.DOMAIN_SPEC_REGEX_R);
// The real pattern replacer
macroStringPattern = Pattern.compile(SPFTermsRegexps.MACRO_STRING_REGEX_TOKEN);
// The macro letters pattern
macroLettersExpPattern = Pattern.compile(SPFTermsRegexps.MACRO_LETTER_PATTERN_EXP);
macroLettersPattern = Pattern.compile(SPFTermsRegexps.MACRO_LETTER_PATTERN);
log = logger;
this.dnsProbe = dnsProbe;
}
private static final class AResponseListener implements
SPFCheckerDNSResponseListener {
/**
* @see org.apache.james.jspf.core.SPFCheckerDNSResponseListener#onDNSResponse(org.apache.james.jspf.core.DNSResponse, org.apache.james.jspf.core.SPFSession)
*/
public DNSLookupContinuation onDNSResponse(DNSResponse response, SPFSession session)
throws PermErrorException, NoneException, TempErrorException,
NeutralException {
// just return the default "unknown" if we cannot find anything
// later
session.setClientDomain("unknown");
try {
List<String> records = response.getResponse();
if (records != null && records.size() > 0) {
Iterator<String> i = records.iterator();
while (i.hasNext()) {
String next = i.next();
if (IPAddr.getAddress(session.getIpAddress())
.toString().equals(
IPAddr.getAddress(next).toString())) {
session
.setClientDomain((String) session
.getAttribute(ATTRIBUTE_MACRO_EXPAND_CHECKED_RECORD));
break;
}
}
}
} catch (TimeoutException e) {
// just return the default "unknown".
} catch (PermErrorException e) {
// just return the default "unknown".
}
return null;
}
}
private static final class PTRResponseListener implements
SPFCheckerDNSResponseListener {
/**
* @see org.apache.james.jspf.core.SPFCheckerDNSResponseListener#onDNSResponse(org.apache.james.jspf.core.DNSResponse, org.apache.james.jspf.core.SPFSession)
*/
public DNSLookupContinuation onDNSResponse(DNSResponse response, SPFSession session)
throws PermErrorException, NoneException, TempErrorException,
NeutralException {
try {
boolean ip6 = IPAddr.isIPV6(session.getIpAddress());
List<String> records = response.getResponse();
if (records != null && records.size() > 0) {
String record = records.get(0);
session.setAttribute(ATTRIBUTE_MACRO_EXPAND_CHECKED_RECORD,
record);
return new DNSLookupContinuation(new DNSRequest(record,
ip6 ? DNSRequest.AAAA : DNSRequest.A),
new AResponseListener());
}
} catch (TimeoutException e) {
// just return the default "unknown".
}
session.setClientDomain("unknown");
return null;
}
}
private static final String ATTRIBUTE_MACRO_EXPAND_CHECKED_RECORD = "MacroExpand.checkedRecord";
public DNSLookupContinuation checkExpand(String input, SPFSession session, boolean isExplanation) throws PermErrorException, NoneException {
if (input != null) {
String host = this.expand(input, session, isExplanation);
if (host == null) {
return new DNSLookupContinuation(new DNSRequest(IPAddr
.getAddress(session.getIpAddress()).getReverseIP(),
DNSRequest.PTR), new PTRResponseListener());
}
}
return null;
}
public String expand(String input, MacroData macroData, boolean isExplanation) throws PermErrorException {
try {
if (isExplanation) {
return expandExplanation(input, macroData);
} else {
return expandDomain(input, macroData);
}
} catch (RequireClientDomainException e) {
return null;
}
}
/**
* This method expand the given a explanation
*
* @param input
* The explanation which should be expanded
* @return expanded The expanded explanation
* @throws PermErrorException
* Get thrown if invalid macros are used
* @throws RequireClientDomain
*/
private String expandExplanation(String input, MacroData macroData) throws PermErrorException, RequireClientDomainException {
log.debug("Start do expand explanation: " + input);
String[] parts = input.split(" ");
StringBuffer res = new StringBuffer();
for (int i = 0; i < parts.length; i++) {
if (i > 0) res.append(" ");
res.append(expandMacroString(parts[i], macroData, true));
}
log.debug("Done expand explanation: " + res);
return res.toString();
}
/**
* This method expand the given domain. So all known macros get replaced
*
* @param input
* The domain which should be expand
* @return expanded The domain with replaced macros
* @throws PermErrorException
* This get thrown if invalid macros are used
* @throws RequireClientDomain
*/
private String expandDomain(String input, MacroData macroData) throws PermErrorException, RequireClientDomainException {
log.debug("Start expand domain: " + input);
Matcher inputMatcher = domainSpecPattern.matcher(input);
if (!inputMatcher.matches() || inputMatcher.groupCount() != 2) {
throw new PermErrorException("Invalid DomainSpec: "+input);
}
StringBuffer res = new StringBuffer();
if (inputMatcher.group(1) != null && inputMatcher.group(1).length() > 0) {
res.append(expandMacroString(inputMatcher.group(1), macroData, false));
}
if (inputMatcher.group(2) != null && inputMatcher.group(2).length() > 0) {
if (inputMatcher.group(2).startsWith(".")) {
res.append(inputMatcher.group(2));
} else {
res.append(expandMacroString(inputMatcher.group(2), macroData, false));
}
}
String domainName = expandMacroString(input, macroData, false);
// reduce to less than 255 characters, deleting subdomains from left
int split = 0;
while (domainName.length() > 255 && split > -1) {
split = domainName.indexOf(".");
domainName = domainName.substring(split + 1);
}
log.debug("Domain expanded: " + domainName);
return domainName;
}
/**
* Expand the given String
*
* @param input
* The inputString which should get expanded
* @return expanded The expanded given String
* @throws PermErrorException
* This get thrown if invalid macros are used
* @throws RequireClientDomain
*/
private String expandMacroString(String input, MacroData macroData, boolean isExplanation) throws PermErrorException, RequireClientDomainException {
StringBuffer decodedValue = new StringBuffer();
Matcher inputMatcher = macroStringPattern.matcher(input);
String macroCell;
int pos = 0;
while (inputMatcher.find()) {
String match2 = inputMatcher.group();
if (pos != inputMatcher.start()) {
throw new PermErrorException("Middle part does not match: "+input.substring(0,pos)+">>"+input.substring(pos, inputMatcher.start())+"<<"+input.substring(inputMatcher.start())+" ["+input+"]");
}
if (match2.length() > 0) {
if (match2.startsWith("%{")) {
macroCell = input.substring(inputMatcher.start() + 2, inputMatcher
.end() - 1);
inputMatcher
.appendReplacement(decodedValue, escapeForMatcher(replaceCell(macroCell, macroData, isExplanation)));
} else if (match2.length() == 2 && match2.startsWith("%")) {
// handle the % escaping
/*
* From RFC4408:
*
* A literal "%" is expressed by "%%".
* "%_" expands to a single " " space.
* "%-" expands to a URL-encoded space, viz., "%20".
*/
if ("%_".equals(match2)) {
inputMatcher.appendReplacement(decodedValue, " ");
} else if ("%-".equals(match2)) {
inputMatcher.appendReplacement(decodedValue, "%20");
} else {
inputMatcher.appendReplacement(decodedValue, escapeForMatcher(match2.substring(1)));
}
}
}
pos = inputMatcher.end();
}
if (input.length() != pos) {
throw new PermErrorException("End part does not match: "+input.substring(pos));
}
inputMatcher.appendTail(decodedValue);
return decodedValue.toString();
}
/**
* Replace the macros in given String
*
* @param replaceValue
* The String in which known macros should get replaced
* @return returnData The String with replaced macros
* @throws PermErrorException
* Get thrown if an error in processing happen
* @throws RequireClientDomain
*/
private String replaceCell(String replaceValue, MacroData macroData, boolean isExplanation) throws PermErrorException, RequireClientDomainException {
String variable = "";
String domainNumber = "";
boolean isReversed = false;
String delimeters = ".";
// Get only command character so that 'r' command and 'r' modifier don't
// clash
String commandCharacter = replaceValue.substring(0, 1);
Matcher cellMatcher;
// Find command
if (isExplanation) {
cellMatcher = macroLettersExpPattern.matcher(commandCharacter);
} else {
cellMatcher = macroLettersPattern.matcher(commandCharacter);
}
if (cellMatcher.find()) {
if (cellMatcher.group().toUpperCase().equals(cellMatcher.group())) {
variable = encodeURL(matchMacro(cellMatcher.group(), macroData));
} else {
variable = matchMacro(cellMatcher.group(), macroData);
}
// Remove Macro code so that r macro code does not clash with r the
// reverse modifier
replaceValue = replaceValue.substring(1);
} else {
throw new PermErrorException("MacroLetter not found: "+replaceValue);
}
// Find number of domains to use
cellPattern = Pattern.compile("\\d+");
cellMatcher = cellPattern.matcher(replaceValue);
while (cellMatcher.find()) {
domainNumber = cellMatcher.group();
if (Integer.parseInt(domainNumber) == 0) {
throw new PermErrorException(
"Digit transformer must be non-zero");
}
}
// find if reversed
cellPattern = Pattern.compile("r");
cellMatcher = cellPattern.matcher(replaceValue);
while (cellMatcher.find()) {
isReversed = true;
}
// find delimeters
cellPattern = Pattern.compile("[\\.\\-\\+\\,\\/\\_\\=]+");
cellMatcher = cellPattern.matcher(replaceValue);
while (cellMatcher.find()) {
delimeters = cellMatcher.group();
}
// Reverse domains as necessary
ArrayList<String> data = split(variable, delimeters);
if (isReversed) {
data = reverse(data);
}
// Truncate domain name to number of sub sections
String returnData;
if (!domainNumber.equals("")) {
returnData = subset(data, Integer.parseInt(domainNumber));
} else {
returnData = subset(data);
}
return returnData;
}
/**
* Get the value for the given macro like descripted in the RFC
*
* @param macro
* The macro we want to get the value for
* @return rValue The value for the given macro
* @throws PermErrorException
* Get thrown if the given variable is an unknown macro
* @throws RequireClientDomain requireClientDomain if the client domain is needed
* and not yet resolved.
*/
private String matchMacro(String macro, MacroData macroData) throws PermErrorException, RequireClientDomainException {
String rValue = null;
String variable = macro.toLowerCase();
if (variable.equalsIgnoreCase("i")) {
rValue = macroData.getMacroIpAddress();
} else if (variable.equalsIgnoreCase("s")) {
rValue = macroData.getMailFrom();
} else if (variable.equalsIgnoreCase("h")) {
rValue = macroData.getHostName();
} else if (variable.equalsIgnoreCase("l")) {
rValue = macroData.getCurrentSenderPart();
} else if (variable.equalsIgnoreCase("d")) {
rValue = macroData.getCurrentDomain();
} else if (variable.equalsIgnoreCase("v")) {
rValue = macroData.getInAddress();
} else if (variable.equalsIgnoreCase("t")) {
rValue = Long.toString(macroData.getTimeStamp());
} else if (variable.equalsIgnoreCase("c")) {
rValue = macroData.getReadableIP();
} else if (variable.equalsIgnoreCase("p")) {
rValue = macroData.getClientDomain();
if (rValue == null) {
throw new RequireClientDomainException();
}
} else if (variable.equalsIgnoreCase("o")) {
rValue = macroData.getSenderDomain();
} else if (variable.equalsIgnoreCase("r")) {
rValue = macroData.getReceivingDomain();
if (rValue == null) {
rValue = "unknown";
List<String> dNames = dnsProbe.getLocalDomainNames();
for (int i = 0; i < dNames.size(); i++) {
// check if the domainname is a FQDN
if (SPF1Utils.checkFQDN(dNames.get(i).toString())) {
rValue = dNames.get(i).toString();
if (macroData instanceof SPFSession) {
((SPFSession) macroData).setReceivingDomain(rValue);
}
break;
}
}
}
}
if (rValue == null) {
throw new PermErrorException("Unknown command : " + variable);
} else {
log.debug("Used macro: " + macro + " replaced with: " + rValue);
return rValue;
}
}
/**
* Create an ArrayList by the given String. The String get splitted by given
* delimeters and one entry in the Array will be made for each splited
* String
*
* @param data
* The String we want to put in the Array
* @param delimeters
* The delimeter we want to use to split the String
* @return ArrayList which contains the String parts
*/
private ArrayList<String> split(String data, String delimeters) {
String currentChar;
StringBuffer element = new StringBuffer();
ArrayList<String> splitParts = new ArrayList<String>();
for (int i = 0; i < data.length(); i++) {
currentChar = data.substring(i, i + 1);
if (delimeters.indexOf(currentChar) > -1) {
splitParts.add(element.toString());
element.setLength(0);
} else {
element.append(currentChar);
}
}
splitParts.add(element.toString());
return splitParts;
}
/**
* Reverse an ArrayList
*
* @param data
* The ArrayList we want to get reversed
* @return reversed The reversed given ArrayList
*/
private ArrayList<String> reverse(ArrayList<String> data) {
ArrayList<String> reversed = new ArrayList<String>();
for (int i = 0; i < data.size(); i++) {
reversed.add(0, data.get(i));
}
return reversed;
}
/**
* @see #subset(ArrayList, int)
*/
private String subset(ArrayList<String> data) {
return subset(data, data.size());
}
/**
* Convert a ArrayList to a String which holds the entries seperated by dots
*
* @param data The ArrayList which should be converted
* @param length The ArrayLength
* @return A String which holds all entries seperated by dots
*/
private String subset(ArrayList<String> data, int length) {
StringBuffer buildString = new StringBuffer();
if (data.size() < length) {
length = data.size();
}
int start = data.size() - length;
for (int i = start; i < data.size(); i++) {
if (buildString.length() > 0) {
buildString.append(".");
}
buildString.append(data.get(i));
}
return buildString.toString();
}
/**
* Encode the given URL to UTF-8
*
* @param data
* url to encode
* @return encoded URL
*/
private String encodeURL(String data) {
try {
// TODO URLEncoder method is not RFC2396 compatible, known
// difference
// is Space character gets converted to "+" rather than "%20"
// Is there anything else which is not correct with URLEncoder?
// Couldn't find a RFC2396 encoder
data = URLEncoder.encode(data, "UTF-8");
} catch (UnsupportedEncodingException e) {
// This shouldn't happen ignore it!
}
// workaround for the above descripted problem
return data.replaceAll("\\+", "%20");
}
/**
* Because Dollar signs may be treated as references to captured subsequences in method Matcher.appendReplacement
* its necessary to escape Dollar signs because its allowed in the local-part of an emailaddress.
*
* See JSPF-71 for the bugreport
*
* @param raw
* @return escaped string
*/
private String escapeForMatcher(String raw) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (c == '$' || c == '\\') {
sb.append('\\');
}
sb.append(c);
}
return sb.toString();
}
}