001 /****************************************************************
002 * Licensed to the Apache Software Foundation (ASF) under one *
003 * or more contributor license agreements. See the NOTICE file *
004 * distributed with this work for additional information *
005 * regarding copyright ownership. The ASF licenses this file *
006 * to you under the Apache License, Version 2.0 (the *
007 * "License"); you may not use this file except in compliance *
008 * with the License. You may obtain a copy of the License at *
009 * *
010 * http://www.apache.org/licenses/LICENSE-2.0 *
011 * *
012 * Unless required by applicable law or agreed to in writing, *
013 * software distributed under the License is distributed on an *
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
015 * KIND, either express or implied. See the License for the *
016 * specific language governing permissions and limitations *
017 * under the License. *
018 ****************************************************************/
019
020
021 package org.apache.james.jspf.core;
022
023 /**
024 * This Class is used to convert all macros which can used in SPF-Records to the
025 * right values!
026 *
027 */
028
029 import org.apache.james.jspf.core.exceptions.NeutralException;
030 import org.apache.james.jspf.core.exceptions.NoneException;
031 import org.apache.james.jspf.core.exceptions.PermErrorException;
032 import org.apache.james.jspf.core.exceptions.TempErrorException;
033 import org.apache.james.jspf.core.exceptions.TimeoutException;
034
035 import java.io.UnsupportedEncodingException;
036 import java.net.URLEncoder;
037 import java.util.ArrayList;
038 import java.util.Iterator;
039 import java.util.List;
040 import java.util.regex.Matcher;
041 import java.util.regex.Pattern;
042
043 public class MacroExpand {
044
045 private Pattern domainSpecPattern;
046
047 private Pattern macroStringPattern;
048
049 private Pattern macroLettersPattern;
050
051 private Pattern macroLettersExpPattern;
052
053 private Pattern cellPattern;
054
055 private Logger log;
056
057 private DNSService dnsProbe;
058
059 public static final boolean EXPLANATION = true;
060
061 public static final boolean DOMAIN = false;
062
063 public static class RequireClientDomainException extends Exception {
064
065 private static final long serialVersionUID = 3834282981657676530L;
066
067 }
068
069 /**
070 * Construct MacroExpand
071 *
072 * @param logger the logget to use
073 * @param dnsProbe the dns service to use
074 */
075 public MacroExpand(Logger logger, DNSService dnsProbe) {
076 // This matches 2 groups
077 domainSpecPattern = Pattern.compile(SPFTermsRegexps.DOMAIN_SPEC_REGEX_R);
078 // The real pattern replacer
079 macroStringPattern = Pattern.compile(SPFTermsRegexps.MACRO_STRING_REGEX_TOKEN);
080 // The macro letters pattern
081 macroLettersExpPattern = Pattern.compile(SPFTermsRegexps.MACRO_LETTER_PATTERN_EXP);
082 macroLettersPattern = Pattern.compile(SPFTermsRegexps.MACRO_LETTER_PATTERN);
083 log = logger;
084 this.dnsProbe = dnsProbe;
085 }
086
087
088 private static final class AResponseListener implements
089 SPFCheckerDNSResponseListener {
090
091 /**
092 * @see org.apache.james.jspf.core.SPFCheckerDNSResponseListener#onDNSResponse(org.apache.james.jspf.core.DNSResponse, org.apache.james.jspf.core.SPFSession)
093 */
094 public DNSLookupContinuation onDNSResponse(DNSResponse response, SPFSession session)
095 throws PermErrorException, NoneException, TempErrorException,
096 NeutralException {
097 // just return the default "unknown" if we cannot find anything
098 // later
099 session.setClientDomain("unknown");
100 try {
101 List<String> records = response.getResponse();
102 if (records != null && records.size() > 0) {
103 Iterator<String> i = records.iterator();
104 while (i.hasNext()) {
105 String next = i.next();
106 if (IPAddr.getAddress(session.getIpAddress())
107 .toString().equals(
108 IPAddr.getAddress(next).toString())) {
109 session
110 .setClientDomain((String) session
111 .getAttribute(ATTRIBUTE_MACRO_EXPAND_CHECKED_RECORD));
112 break;
113 }
114 }
115 }
116 } catch (TimeoutException e) {
117 // just return the default "unknown".
118 } catch (PermErrorException e) {
119 // just return the default "unknown".
120 }
121 return null;
122 }
123 }
124
125 private static final class PTRResponseListener implements
126 SPFCheckerDNSResponseListener {
127
128 /**
129 * @see org.apache.james.jspf.core.SPFCheckerDNSResponseListener#onDNSResponse(org.apache.james.jspf.core.DNSResponse, org.apache.james.jspf.core.SPFSession)
130 */
131 public DNSLookupContinuation onDNSResponse(DNSResponse response, SPFSession session)
132 throws PermErrorException, NoneException, TempErrorException,
133 NeutralException {
134
135 try {
136 boolean ip6 = IPAddr.isIPV6(session.getIpAddress());
137 List<String> records = response.getResponse();
138
139 if (records != null && records.size() > 0) {
140 String record = records.get(0);
141 session.setAttribute(ATTRIBUTE_MACRO_EXPAND_CHECKED_RECORD,
142 record);
143
144 return new DNSLookupContinuation(new DNSRequest(record,
145 ip6 ? DNSRequest.AAAA : DNSRequest.A),
146 new AResponseListener());
147
148 }
149 } catch (TimeoutException e) {
150 // just return the default "unknown".
151 session.setClientDomain("unknown");
152 }
153 return null;
154
155 }
156 }
157
158 private static final String ATTRIBUTE_MACRO_EXPAND_CHECKED_RECORD = "MacroExpand.checkedRecord";
159
160 public DNSLookupContinuation checkExpand(String input, SPFSession session, boolean isExplanation) throws PermErrorException, NoneException {
161 if (input != null) {
162 String host = this.expand(input, session, isExplanation);
163 if (host == null) {
164
165 return new DNSLookupContinuation(new DNSRequest(IPAddr
166 .getAddress(session.getIpAddress()).getReverseIP(),
167 DNSRequest.PTR), new PTRResponseListener());
168 }
169 }
170 return null;
171 }
172
173 public String expand(String input, MacroData macroData, boolean isExplanation) throws PermErrorException {
174 try {
175 if (isExplanation) {
176 return expandExplanation(input, macroData);
177 } else {
178 return expandDomain(input, macroData);
179 }
180 } catch (RequireClientDomainException e) {
181 return null;
182 }
183 }
184
185 /**
186 * This method expand the given a explanation
187 *
188 * @param input
189 * The explanation which should be expanded
190 * @return expanded The expanded explanation
191 * @throws PermErrorException
192 * Get thrown if invalid macros are used
193 * @throws RequireClientDomain
194 */
195 private String expandExplanation(String input, MacroData macroData) throws PermErrorException, RequireClientDomainException {
196
197 log.debug("Start do expand explanation: " + input);
198
199 String[] parts = input.split(" ");
200 StringBuffer res = new StringBuffer();
201 for (int i = 0; i < parts.length; i++) {
202 if (i > 0) res.append(" ");
203 res.append(expandMacroString(parts[i], macroData, true));
204 }
205 log.debug("Done expand explanation: " + res);
206
207 return res.toString();
208 }
209
210 /**
211 * This method expand the given domain. So all known macros get replaced
212 *
213 * @param input
214 * The domain which should be expand
215 * @return expanded The domain with replaced macros
216 * @throws PermErrorException
217 * This get thrown if invalid macros are used
218 * @throws RequireClientDomain
219 */
220 private String expandDomain(String input, MacroData macroData) throws PermErrorException, RequireClientDomainException {
221
222 log.debug("Start expand domain: " + input);
223
224 Matcher inputMatcher = domainSpecPattern.matcher(input);
225 if (!inputMatcher.matches() || inputMatcher.groupCount() != 2) {
226 throw new PermErrorException("Invalid DomainSpec: "+input);
227 }
228
229 StringBuffer res = new StringBuffer();
230 if (inputMatcher.group(1) != null && inputMatcher.group(1).length() > 0) {
231 res.append(expandMacroString(inputMatcher.group(1), macroData, false));
232 }
233 if (inputMatcher.group(2) != null && inputMatcher.group(2).length() > 0) {
234 if (inputMatcher.group(2).startsWith(".")) {
235 res.append(inputMatcher.group(2));
236 } else {
237 res.append(expandMacroString(inputMatcher.group(2), macroData, false));
238 }
239 }
240
241 String domainName = expandMacroString(input, macroData, false);
242 // reduce to less than 255 characters, deleting subdomains from left
243 int split = 0;
244 while (domainName.length() > 255 && split > -1) {
245 split = domainName.indexOf(".");
246 domainName = domainName.substring(split + 1);
247 }
248
249 log.debug("Domain expanded: " + domainName);
250
251 return domainName;
252 }
253
254 /**
255 * Expand the given String
256 *
257 * @param input
258 * The inputString which should get expanded
259 * @return expanded The expanded given String
260 * @throws PermErrorException
261 * This get thrown if invalid macros are used
262 * @throws RequireClientDomain
263 */
264 private String expandMacroString(String input, MacroData macroData, boolean isExplanation) throws PermErrorException, RequireClientDomainException {
265
266 StringBuffer decodedValue = new StringBuffer();
267 Matcher inputMatcher = macroStringPattern.matcher(input);
268 String macroCell;
269 int pos = 0;
270
271 while (inputMatcher.find()) {
272 String match2 = inputMatcher.group();
273 if (pos != inputMatcher.start()) {
274 throw new PermErrorException("Middle part does not match: "+input.substring(0,pos)+">>"+input.substring(pos, inputMatcher.start())+"<<"+input.substring(inputMatcher.start())+" ["+input+"]");
275 }
276 if (match2.length() > 0) {
277 if (match2.startsWith("%{")) {
278 macroCell = input.substring(inputMatcher.start() + 2, inputMatcher
279 .end() - 1);
280 inputMatcher
281 .appendReplacement(decodedValue, escapeForMatcher(replaceCell(macroCell, macroData, isExplanation)));
282 } else if (match2.length() == 2 && match2.startsWith("%")) {
283 // handle the % escaping
284 /*
285 * From RFC4408:
286 *
287 * A literal "%" is expressed by "%%".
288 * "%_" expands to a single " " space.
289 * "%-" expands to a URL-encoded space, viz., "%20".
290 */
291 if ("%_".equals(match2)) {
292 inputMatcher.appendReplacement(decodedValue, " ");
293 } else if ("%-".equals(match2)) {
294 inputMatcher.appendReplacement(decodedValue, "%20");
295 } else {
296 inputMatcher.appendReplacement(decodedValue, escapeForMatcher(match2.substring(1)));
297 }
298 }
299 }
300
301 pos = inputMatcher.end();
302 }
303
304 if (input.length() != pos) {
305 throw new PermErrorException("End part does not match: "+input.substring(pos));
306 }
307
308 inputMatcher.appendTail(decodedValue);
309
310 return decodedValue.toString();
311 }
312
313 /**
314 * Replace the macros in given String
315 *
316 * @param replaceValue
317 * The String in which known macros should get replaced
318 * @return returnData The String with replaced macros
319 * @throws PermErrorException
320 * Get thrown if an error in processing happen
321 * @throws RequireClientDomain
322 */
323 private String replaceCell(String replaceValue, MacroData macroData, boolean isExplanation) throws PermErrorException, RequireClientDomainException {
324
325 String variable = "";
326 String domainNumber = "";
327 boolean isReversed = false;
328 String delimeters = ".";
329
330
331 // Get only command character so that 'r' command and 'r' modifier don't
332 // clash
333 String commandCharacter = replaceValue.substring(0, 1);
334 Matcher cellMatcher;
335 // Find command
336 if (isExplanation) {
337 cellMatcher = macroLettersExpPattern.matcher(commandCharacter);
338 } else {
339 cellMatcher = macroLettersPattern.matcher(commandCharacter);
340 }
341 if (cellMatcher.find()) {
342 if (cellMatcher.group().toUpperCase().equals(cellMatcher.group())) {
343 variable = encodeURL(matchMacro(cellMatcher.group(), macroData));
344 } else {
345 variable = matchMacro(cellMatcher.group(), macroData);
346 }
347 // Remove Macro code so that r macro code does not clash with r the
348 // reverse modifier
349 replaceValue = replaceValue.substring(1);
350 } else {
351 throw new PermErrorException("MacroLetter not found: "+replaceValue);
352 }
353
354 // Find number of domains to use
355 cellPattern = Pattern.compile("\\d+");
356 cellMatcher = cellPattern.matcher(replaceValue);
357 while (cellMatcher.find()) {
358 domainNumber = cellMatcher.group();
359 if (Integer.parseInt(domainNumber) == 0) {
360 throw new PermErrorException(
361 "Digit transformer must be non-zero");
362 }
363 }
364 // find if reversed
365 cellPattern = Pattern.compile("r");
366 cellMatcher = cellPattern.matcher(replaceValue);
367 while (cellMatcher.find()) {
368 isReversed = true;
369 }
370
371 // find delimeters
372 cellPattern = Pattern.compile("[\\.\\-\\+\\,\\/\\_\\=]+");
373 cellMatcher = cellPattern.matcher(replaceValue);
374 while (cellMatcher.find()) {
375 delimeters = cellMatcher.group();
376 }
377
378 // Reverse domains as necessary
379 ArrayList<String> data = split(variable, delimeters);
380 if (isReversed) {
381 data = reverse(data);
382 }
383
384 // Truncate domain name to number of sub sections
385 String returnData;
386 if (!domainNumber.equals("")) {
387 returnData = subset(data, Integer.parseInt(domainNumber));
388 } else {
389 returnData = subset(data);
390 }
391
392 return returnData;
393
394 }
395
396 /**
397 * Get the value for the given macro like descripted in the RFC
398 *
399 * @param macro
400 * The macro we want to get the value for
401 * @return rValue The value for the given macro
402 * @throws PermErrorException
403 * Get thrown if the given variable is an unknown macro
404 * @throws RequireClientDomain requireClientDomain if the client domain is needed
405 * and not yet resolved.
406 */
407 private String matchMacro(String macro, MacroData macroData) throws PermErrorException, RequireClientDomainException {
408
409 String rValue = null;
410
411 String variable = macro.toLowerCase();
412 if (variable.equalsIgnoreCase("i")) {
413 rValue = macroData.getMacroIpAddress();
414 } else if (variable.equalsIgnoreCase("s")) {
415 rValue = macroData.getMailFrom();
416 } else if (variable.equalsIgnoreCase("h")) {
417 rValue = macroData.getHostName();
418 } else if (variable.equalsIgnoreCase("l")) {
419 rValue = macroData.getCurrentSenderPart();
420 } else if (variable.equalsIgnoreCase("d")) {
421 rValue = macroData.getCurrentDomain();
422 } else if (variable.equalsIgnoreCase("v")) {
423 rValue = macroData.getInAddress();
424 } else if (variable.equalsIgnoreCase("t")) {
425 rValue = Long.toString(macroData.getTimeStamp());
426 } else if (variable.equalsIgnoreCase("c")) {
427 rValue = macroData.getReadableIP();
428 } else if (variable.equalsIgnoreCase("p")) {
429 rValue = macroData.getClientDomain();
430 if (rValue == null) {
431 throw new RequireClientDomainException();
432 }
433 } else if (variable.equalsIgnoreCase("o")) {
434 rValue = macroData.getSenderDomain();
435 } else if (variable.equalsIgnoreCase("r")) {
436 rValue = macroData.getReceivingDomain();
437 if (rValue == null) {
438 rValue = "unknown";
439 List<String> dNames = dnsProbe.getLocalDomainNames();
440
441 for (int i = 0; i < dNames.size(); i++) {
442 // check if the domainname is a FQDN
443 if (SPF1Utils.checkFQDN(dNames.get(i).toString())) {
444 rValue = dNames.get(i).toString();
445 if (macroData instanceof SPFSession) {
446 ((SPFSession) macroData).setReceivingDomain(rValue);
447 }
448 break;
449 }
450 }
451 }
452 }
453
454 if (rValue == null) {
455 throw new PermErrorException("Unknown command : " + variable);
456
457 } else {
458 log.debug("Used macro: " + macro + " replaced with: " + rValue);
459
460 return rValue;
461 }
462 }
463
464 /**
465 * Create an ArrayList by the given String. The String get splitted by given
466 * delimeters and one entry in the Array will be made for each splited
467 * String
468 *
469 * @param data
470 * The String we want to put in the Array
471 * @param delimeters
472 * The delimeter we want to use to split the String
473 * @return ArrayList which contains the String parts
474 */
475 private ArrayList<String> split(String data, String delimeters) {
476
477 String currentChar;
478 StringBuffer element = new StringBuffer();
479 ArrayList<String> splitParts = new ArrayList<String>();
480
481 for (int i = 0; i < data.length(); i++) {
482 currentChar = data.substring(i, i + 1);
483 if (delimeters.indexOf(currentChar) > -1) {
484 splitParts.add(element.toString());
485 element.setLength(0);
486 } else {
487 element.append(currentChar);
488 }
489 }
490 splitParts.add(element.toString());
491 return splitParts;
492 }
493
494 /**
495 * Reverse an ArrayList
496 *
497 * @param data
498 * The ArrayList we want to get reversed
499 * @return reversed The reversed given ArrayList
500 */
501 private ArrayList<String> reverse(ArrayList<String> data) {
502
503 ArrayList<String> reversed = new ArrayList<String>();
504 for (int i = 0; i < data.size(); i++) {
505 reversed.add(0, data.get(i));
506 }
507 return reversed;
508 }
509
510 /**
511 * @see #subset(ArrayList, int)
512 */
513 private String subset(ArrayList<String> data) {
514 return subset(data, data.size());
515 }
516
517 /**
518 * Convert a ArrayList to a String which holds the entries seperated by dots
519 *
520 * @param data The ArrayList which should be converted
521 * @param length The ArrayLength
522 * @return A String which holds all entries seperated by dots
523 */
524 private String subset(ArrayList<String> data, int length) {
525
526 StringBuffer buildString = new StringBuffer();
527 if (data.size() < length) {
528 length = data.size();
529 }
530 int start = data.size() - length;
531 for (int i = start; i < data.size(); i++) {
532 if (buildString.length() > 0) {
533 buildString.append(".");
534 }
535 buildString.append(data.get(i));
536 }
537 return buildString.toString();
538
539 }
540
541 /**
542 * Encode the given URL to UTF-8
543 *
544 * @param data
545 * url to encode
546 * @return encoded URL
547 */
548 private String encodeURL(String data) {
549
550 try {
551 // TODO URLEncoder method is not RFC2396 compatible, known
552 // difference
553 // is Space character gets converted to "+" rather than "%20"
554 // Is there anything else which is not correct with URLEncoder?
555 // Couldn't find a RFC2396 encoder
556 data = URLEncoder.encode(data, "UTF-8");
557 } catch (UnsupportedEncodingException e) {
558 // This shouldn't happen ignore it!
559 }
560
561 // workaround for the above descripted problem
562 return data.replaceAll("\\+", "%20");
563
564 }
565
566 /**
567 * Because Dollar signs may be treated as references to captured subsequences in method Matcher.appendReplacement
568 * its necessary to escape Dollar signs because its allowed in the local-part of an emailaddress.
569 *
570 * See JSPF-71 for the bugreport
571 *
572 * @param raw
573 * @return escaped string
574 */
575 private String escapeForMatcher(String raw) {
576 StringBuffer sb = new StringBuffer();
577
578 for (int i = 0; i < raw.length(); i++) {
579 char c = raw.charAt(i);
580 if (c == '$' || c == '\\') {
581 sb.append('\\');
582 }
583 sb.append(c);
584 }
585 return sb.toString();
586 }
587
588 }