View Javadoc

1   /****************************************************************
2    * Licensed to the Apache Software Foundation (ASF) under one   *
3    * or more contributor license agreements.  See the NOTICE file *
4    * distributed with this work for additional information        *
5    * regarding copyright ownership.  The ASF licenses this file   *
6    * to you under the Apache License, Version 2.0 (the            *
7    * "License"); you may not use this file except in compliance   *
8    * with the License.  You may obtain a copy of the License at   *
9    *                                                              *
10   *   http://www.apache.org/licenses/LICENSE-2.0                 *
11   *                                                              *
12   * Unless required by applicable law or agreed to in writing,   *
13   * software distributed under the License is distributed on an  *
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
15   * KIND, either express or implied.  See the License for the    *
16   * specific language governing permissions and limitations      *
17   * under the License.                                           *
18   ****************************************************************/
19  
20  
21  package org.apache.james.jspf.core;
22  
23  /**
24   * This Class is used to convert all macros which can used in SPF-Records to the
25   * right values!
26   * 
27   */
28  
29  import org.apache.james.jspf.core.exceptions.NeutralException;
30  import org.apache.james.jspf.core.exceptions.NoneException;
31  import org.apache.james.jspf.core.exceptions.PermErrorException;
32  import org.apache.james.jspf.core.exceptions.TempErrorException;
33  import org.apache.james.jspf.core.exceptions.TimeoutException;
34  
35  import java.io.UnsupportedEncodingException;
36  import java.net.URLEncoder;
37  import java.util.ArrayList;
38  import java.util.Iterator;
39  import java.util.List;
40  import java.util.regex.Matcher;
41  import java.util.regex.Pattern;
42  
43  public class MacroExpand {
44  
45      private Pattern domainSpecPattern;
46  
47      private Pattern macroStringPattern;
48  
49      private Pattern macroLettersPattern;
50  
51      private Pattern macroLettersExpPattern;
52  
53      private Pattern cellPattern;
54  
55      private Logger log;
56  
57      private DNSService dnsProbe;
58  
59      public static final boolean EXPLANATION = true;
60      
61      public static final boolean DOMAIN = false;
62      
63      public static class RequireClientDomainException extends Exception {
64  
65          private static final long serialVersionUID = 3834282981657676530L;
66          
67      }
68  
69      /**
70       * Construct MacroExpand
71       * 
72       * @param logger the logget to use
73       * @param dnsProbe the dns service to use
74       */
75      public MacroExpand(Logger logger, DNSService dnsProbe) {
76          // This matches 2 groups
77          domainSpecPattern = Pattern.compile(SPFTermsRegexps.DOMAIN_SPEC_REGEX_R);
78          // The real pattern replacer
79          macroStringPattern = Pattern.compile(SPFTermsRegexps.MACRO_STRING_REGEX_TOKEN);
80          // The macro letters pattern
81          macroLettersExpPattern = Pattern.compile(SPFTermsRegexps.MACRO_LETTER_PATTERN_EXP);
82          macroLettersPattern = Pattern.compile(SPFTermsRegexps.MACRO_LETTER_PATTERN);
83          log = logger;
84          this.dnsProbe = dnsProbe;
85      }
86      
87  
88      private static final class AResponseListener implements
89              SPFCheckerDNSResponseListener {
90          
91          /**
92           * @see org.apache.james.jspf.core.SPFCheckerDNSResponseListener#onDNSResponse(org.apache.james.jspf.core.DNSResponse, org.apache.james.jspf.core.SPFSession)
93           */
94          public DNSLookupContinuation onDNSResponse(DNSResponse response, SPFSession session)
95                  throws PermErrorException, NoneException, TempErrorException,
96                  NeutralException {
97              // just return the default "unknown" if we cannot find anything
98              // later
99              session.setClientDomain("unknown");
100             try {
101                 List records = response.getResponse();
102                 if (records != null && records.size() > 0) {
103                     Iterator i = records.iterator();
104                     while (i.hasNext()) {
105                         String next = (String) 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 records = response.getResponse();
138 
139                 if (records != null && records.size() > 0) {
140                     String record = (String) 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 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 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 split(String data, String delimeters) {
476 
477         String currentChar;
478         StringBuffer element = new StringBuffer();
479         ArrayList splitParts = new ArrayList();
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 reverse(ArrayList data) {
502 
503         ArrayList reversed = new ArrayList();
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 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 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 }