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 */ 019package org.apache.wiki.filters; 020 021import com.github.difflib.DiffUtils; 022import com.github.difflib.patch.AbstractDelta; 023import com.github.difflib.patch.Patch; 024import org.apache.commons.lang3.StringUtils; 025import org.apache.commons.lang3.time.StopWatch; 026import org.apache.logging.log4j.LogManager; 027import org.apache.logging.log4j.Logger; 028import org.apache.oro.text.regex.MalformedPatternException; 029import org.apache.oro.text.regex.MatchResult; 030import org.apache.oro.text.regex.Pattern; 031import org.apache.oro.text.regex.PatternCompiler; 032import org.apache.oro.text.regex.PatternMatcher; 033import org.apache.oro.text.regex.Perl5Compiler; 034import org.apache.oro.text.regex.Perl5Matcher; 035import org.apache.wiki.InternalWikiException; 036import org.apache.wiki.api.core.Attachment; 037import org.apache.wiki.api.core.Context; 038import org.apache.wiki.api.core.ContextEnum; 039import org.apache.wiki.api.core.Engine; 040import org.apache.wiki.api.core.Page; 041import org.apache.wiki.api.exceptions.ProviderException; 042import org.apache.wiki.api.exceptions.RedirectException; 043import org.apache.wiki.api.filters.BasePageFilter; 044import org.apache.wiki.api.providers.WikiProvider; 045import org.apache.wiki.attachment.AttachmentManager; 046import org.apache.wiki.auth.user.UserProfile; 047import org.apache.wiki.pages.PageManager; 048import org.apache.wiki.ui.EditorManager; 049import org.apache.wiki.util.FileUtil; 050import org.apache.wiki.util.HttpUtil; 051import org.apache.wiki.util.TextUtil; 052 053import jakarta.servlet.http.HttpServletRequest; 054import jakarta.servlet.http.HttpServletResponse; 055import jakarta.servlet.jsp.PageContext; 056import java.io.BufferedReader; 057import java.io.IOException; 058import java.io.InputStream; 059import java.io.InputStreamReader; 060import java.io.StringReader; 061import java.io.StringWriter; 062import java.nio.charset.StandardCharsets; 063import java.util.ArrayList; 064import java.util.Arrays; 065import java.util.Collection; 066import java.util.Date; 067import java.util.Iterator; 068import java.util.List; 069import java.util.Properties; 070import java.util.Random; 071import java.util.StringTokenizer; 072import java.util.Vector; 073import java.util.concurrent.ThreadLocalRandom; 074import net.thauvin.erik.akismet.Akismet; 075import net.thauvin.erik.akismet.AkismetComment; 076 077 078/** 079 * This is Herb, the JSPWiki spamfilter that can also do choke modifications. 080 * 081 * Parameters: 082 * <ul> 083 * <li>wordlist - Page name where the spamword regexps are found. Use [{SET spamwords='regexp list separated with spaces'}] on 084 * that page. Default is "SpamFilterWordList". 085 * <li>IPlist - Page name where the IP regexps are found. Use [{SET ips='regexp list separated with spaces'}] on 086 * that page. Default is "SpamFilterIPList". 087 * <li>maxpagenamelength - Maximum page name length. Default is 100. 088 * <li>blacklist - The name of an attachment containing the list of spam patterns, one per line. Default is 089 * "SpamFilterWordList/blacklist.txt"</li> 090 * <li>errorpage - The page to which the user is redirected. Has a special variable $msg which states the reason. Default is "RejectedMessage". 091 * <li>pagechangesinminute - How many page changes are allowed/minute. Default is 5.</li> 092 * <li>similarchanges - How many similar page changes are allowed before the host is banned. Default is 2. (since 2.4.72)</li> 093 * <li>bantime - How long an IP address stays on the temporary ban list (default is 60 for 60 minutes).</li> 094 * <li>maxurls - How many URLs can be added to the page before it is considered spam (default is 5)</li> 095 * <li>akismet-apikey - The Akismet API key (see akismet.org)</li> 096 * <li>ignoreauthenticated - If set to "true", all authenticated users are ignored and never caught in SpamFilter</li> 097 * <li>captcha - Sets the captcha technology to use. Current allowed values are "none". "asirra" was previously supported however that service has been discontinued.</li> 098 * <li>strategy - Sets the filtering strategy to use. If set to "eager", will stop at the first probable 099 * match, and won't consider any other tests. This is the default, as it's considerably lighter. If set to "score", will go through all of the tests 100 * and calculates a score for the spam, which is then compared to a filter level value. 101 * </ul> 102 * 103 * <p>Please see the default editors/plain.jsp for examples on how the SpamFilter integrates 104 * with the editor system.</p> 105 * 106 * <p>Changes by admin users are ignored in any case.</p> 107 * 108 * @since 2.1.112 109 */ 110public class SpamFilter extends BasePageFilter { 111 112 private static final String ATTR_SPAMFILTER_SCORE = "spamfilter.score"; 113 private static final String REASON_REGEXP = "Regexp"; 114 private static final String REASON_IP_BANNED_TEMPORARILY = "IPBannedTemporarily"; 115 private static final String REASON_IP_BANNED_PERMANENTLY = "IPBannedPermanently"; 116 private static final String REASON_BOT_TRAP = "BotTrap"; 117 private static final String REASON_AKISMET = "Akismet"; 118 private static final String REASON_TOO_MANY_URLS = "TooManyUrls"; 119 private static final String REASON_SIMILAR_MODIFICATIONS = "SimilarModifications"; 120 private static final String REASON_TOO_MANY_MODIFICATIONS = "TooManyModifications"; 121 private static final String REASON_PAGENAME_TOO_LONG = "PageNameTooLong"; 122 private static final String REASON_UTF8_TRAP = "UTF8Trap"; 123 124 private static final String LISTVAR = "spamwords"; 125 private static final String LISTIPVAR = "ips"; 126 127 private static final Random RANDOM = ThreadLocalRandom.current(); 128 129 /** The filter property name for specifying the page which contains the list of spamwords. Value is <tt>{@value}</tt>. */ 130 public static final String PROP_WORDLIST = "wordlist"; 131 132 /** The filter property name for specifying the page which contains the list of IPs to ban. Value is <tt>{@value}</tt>. */ 133 public static final String PROP_IPLIST = "IPlist"; 134 135 /** The filter property name for specifying the maximum page name length. Value is <tt>{@value}</tt>. */ 136 public static final String PROP_MAX_PAGENAME_LENGTH = "maxpagenamelength"; 137 138 /** The filter property name for the page to which you are directed if Herb rejects your edit. Value is <tt>{@value}</tt>. */ 139 public static final String PROP_ERRORPAGE = "errorpage"; 140 141 /** The filter property name for specifying how many changes is any given IP address 142 * allowed to do per minute. Value is <tt>{@value}</tt>. 143 */ 144 public static final String PROP_PAGECHANGES = "pagechangesinminute"; 145 146 /** The filter property name for specifying how many similar changes are allowed before a host is banned. Value is <tt>{@value}</tt>. */ 147 public static final String PROP_SIMILARCHANGES = "similarchanges"; 148 149 /** The filter property name for specifying how long a host is banned. Value is <tt>{@value}</tt>.*/ 150 public static final String PROP_BANTIME = "bantime"; 151 152 /** The filter property name for the attachment containing the blacklist. Value is <tt>{@value}</tt>.*/ 153 public static final String PROP_BLACKLIST = "blacklist"; 154 155 /** The filter property name for specifying how many URLs can any given edit contain. Value is <tt>{@value}</tt> */ 156 public static final String PROP_MAXURLS = "maxurls"; 157 158 /** The filter property name for specifying the Akismet API-key. Value is <tt>{@value}</tt>. */ 159 public static final String PROP_AKISMET_API_KEY = "akismet-apikey"; 160 161 /** The filter property name for specifying whether authenticated users should be ignored. Value is <tt>{@value}</tt>. */ 162 public static final String PROP_IGNORE_AUTHENTICATED = "ignoreauthenticated"; 163 164 /** The filter property name for specifying groups allowed to bypass the spam filter. Value is <tt>{@value}</tt>. */ 165 public static final String PROP_ALLOWED_GROUPS = "jspwiki.filters.spamfilter.allowedgroups"; 166 167 /** The filter property name for specifying which captcha technology should be used. Value is <tt>{@value}</tt>. */ 168 public static final String PROP_CAPTCHA = "captcha"; 169 170 /** The filter property name for specifying which filter strategy should be used. Value is <tt>{@value}</tt>. */ 171 public static final String PROP_FILTERSTRATEGY = "strategy"; 172 173 /** The string specifying the "eager" strategy. Value is <tt>{@value}</tt>. */ 174 public static final String STRATEGY_EAGER = "eager"; 175 176 /** The string specifying the "score" strategy. Value is <tt>{@value}</tt>. */ 177 public static final String STRATEGY_SCORE = "score"; 178 179 private static final String URL_REGEXP = "(http://|https://|mailto:)([A-Za-z0-9_/\\.\\+\\?\\#\\-\\@=&;]+)"; 180 181 private String m_forbiddenWordsPage = "SpamFilterWordList"; 182 private String m_forbiddenIPsPage = "SpamFilterIPList"; 183 private String m_pageNameMaxLength = "100"; 184 private String m_errorPage = "RejectedMessage"; 185 private String m_blacklist = "SpamFilterWordList/blacklist.txt"; 186 187 private final PatternMatcher m_matcher = new Perl5Matcher(); 188 private final PatternCompiler m_compiler = new Perl5Compiler(); 189 190 private Collection<Pattern> m_spamPatterns; 191 private Collection<Pattern> m_IPPatterns; 192 193 private Date m_lastRebuild = new Date( 0L ); 194 195 private static final Logger C_SPAMLOG = LogManager.getLogger( "SpamLog" ); 196 private static final Logger LOG = LogManager.getLogger( SpamFilter.class ); 197 198 private final Vector<Host> m_temporaryBanList = new Vector<>(); 199 200 private int m_banTime = 60; // minutes 201 202 private final Vector<Host> m_lastModifications = new Vector<>(); 203 204 /** How many times a single IP address can change a page per minute? */ 205 private int m_limitSinglePageChanges = 5; 206 207 /** How many times can you add the exact same string to a page? */ 208 private int m_limitSimilarChanges = 2; 209 210 /** How many URLs can be added at maximum. */ 211 private int m_maxUrls = 10; 212 213 private Pattern m_urlPattern; 214 private Akismet m_akismet; 215 216 private String m_akismetAPIKey; 217 218 /** The limit at which we consider something to be spam. */ 219 private final int m_scoreLimit = 1; 220 221 /** If set to true, will ignore anyone who is in Authenticated role. */ 222 private boolean m_ignoreAuthenticated; 223 224 /** Groups allowed to bypass the filter */ 225 private String[] m_allowedGroups; 226 227 private boolean m_stopAtFirstMatch = true; 228 229 private static String c_hashName; 230 private static long c_lastUpdate; 231 232 /** The HASH_DELAY value is a maximum amount of time that an user can keep 233 * a session open, because after the value has expired, we will invent a new 234 * hash field name. By default this is {@value} hours, which should be ample 235 * time for someone. 236 */ 237 private static final long HASH_DELAY = 24; 238 239 240 /** 241 * {@inheritDoc} 242 */ 243 @Override 244 public void initialize( final Engine engine, final Properties properties ) { 245 m_forbiddenWordsPage = properties.getProperty( PROP_WORDLIST, m_forbiddenWordsPage ); 246 m_forbiddenIPsPage = properties.getProperty( PROP_IPLIST, m_forbiddenIPsPage); 247 m_pageNameMaxLength = properties.getProperty( PROP_MAX_PAGENAME_LENGTH, m_pageNameMaxLength); 248 m_errorPage = properties.getProperty( PROP_ERRORPAGE, m_errorPage ); 249 m_limitSinglePageChanges = TextUtil.getIntegerProperty( properties, PROP_PAGECHANGES, m_limitSinglePageChanges ); 250 251 m_limitSimilarChanges = TextUtil.getIntegerProperty( properties, PROP_SIMILARCHANGES, m_limitSimilarChanges ); 252 253 m_maxUrls = TextUtil.getIntegerProperty( properties, PROP_MAXURLS, m_maxUrls ); 254 m_banTime = TextUtil.getIntegerProperty( properties, PROP_BANTIME, m_banTime ); 255 m_blacklist = properties.getProperty( PROP_BLACKLIST, m_blacklist ); 256 257 m_ignoreAuthenticated = TextUtil.getBooleanProperty( properties, PROP_IGNORE_AUTHENTICATED, m_ignoreAuthenticated ); 258 m_allowedGroups = StringUtils.split( StringUtils.defaultString( properties.getProperty( PROP_ALLOWED_GROUPS, m_blacklist ) ), ',' ); 259 260 try { 261 m_urlPattern = m_compiler.compile( URL_REGEXP ); 262 } catch( final MalformedPatternException e ) { 263 LOG.fatal( "Internal error: Someone put in a faulty pattern.", e ); 264 throw new InternalWikiException( "Faulty pattern." , e); 265 } 266 267 m_akismetAPIKey = TextUtil.getStringProperty( properties, PROP_AKISMET_API_KEY, m_akismetAPIKey ); 268 m_stopAtFirstMatch = TextUtil.getStringProperty( properties, PROP_FILTERSTRATEGY, STRATEGY_EAGER ).equals( STRATEGY_EAGER ); 269 270 LOG.info( "# Spam filter initialized. Temporary ban time " + m_banTime + 271 " mins, max page changes/minute: " + m_limitSinglePageChanges ); 272 } 273 274 private static final int REJECT = 0; 275 private static final int ACCEPT = 1; 276 private static final int NOTE = 2; 277 278 private static String log( final Context ctx, final int type, final String source, String message ) { 279 message = TextUtil.replaceString( message, "\r\n", "\\r\\n" ); 280 message = TextUtil.replaceString( message, "\"", "\\\"" ); 281 282 final String uid = getUniqueID(); 283 final String page = ctx.getPage().getName(); 284 final String addr = ctx.getHttpRequest() != null ? HttpUtil.getRemoteAddress( ctx.getHttpRequest() ) : "-"; 285 final String reason; 286 switch( type ) { 287 case REJECT: reason = "REJECTED"; 288 break; 289 case ACCEPT: reason = "ACCEPTED"; 290 break; 291 case NOTE: reason = "NOTE"; 292 break; 293 default: throw new InternalWikiException( "Illegal type " + type ); 294 } 295 C_SPAMLOG.info( reason + " " + source + " " + uid + " " + addr + " \"" + page + "\" " + message ); 296 297 return uid; 298 } 299 300 /** {@inheritDoc} */ 301 @Override 302 public String preSave( final Context context, final String content ) throws RedirectException { 303 cleanBanList(); 304 refreshBlacklists( context ); 305 final Change change = getChange( context, content ); 306 307 if( !ignoreThisUser( context ) ) { 308 checkBanList( context, change ); 309 checkSinglePageChange( context, change ); 310 checkIPList( context ); 311 checkPatternList( context, change ); 312 checkPageName( context); 313 } 314 315 if( !m_stopAtFirstMatch ) { 316 final Integer score = context.getVariable( ATTR_SPAMFILTER_SCORE ); 317 318 if( score != null && score >= m_scoreLimit ) { 319 throw new RedirectException( "Herb says you got too many points", getRedirectPage( context ) ); 320 } 321 } 322 323 log( context, ACCEPT, "-", change.toString() ); 324 return content; 325 } 326 327 private void checkPageName(final Context context ) throws RedirectException { 328 final Page page = context.getPage(); 329 final String pageName = page.getName(); 330 final int maxlength = Integer.parseInt(m_pageNameMaxLength); 331 if ( pageName.length() > maxlength) { 332 // 333 // Spam filter has a match. 334 // 335 336 final String uid = log( context, REJECT, REASON_PAGENAME_TOO_LONG + "(" + m_pageNameMaxLength + ")" , pageName); 337 338 LOG.info("SPAM:PageNameTooLong (" + uid + "). The length of the page name is too large (" + pageName.length() + " , limit is " + m_pageNameMaxLength + ")"); 339 checkStrategy( context, "Herb says '" + pageName + "' is a bad pageName and I trust Herb! (Incident code " + uid + ")" ); 340 341 } 342 } 343 344 private void checkStrategy(final Context context, final String message ) throws RedirectException { 345 if( m_stopAtFirstMatch ) { 346 throw new RedirectException( message, getRedirectPage( context ) ); 347 } 348 349 Integer score = context.getVariable( ATTR_SPAMFILTER_SCORE ); 350 if( score != null ) { 351 score = score + 1; 352 } else { 353 score = 1; 354 } 355 356 context.setVariable( ATTR_SPAMFILTER_SCORE, score ); 357 } 358 359 /** 360 * Parses a list of patterns and returns a Collection of compiled Pattern objects. 361 * 362 * @param source page containing the list of patterns. 363 * @param list list of patterns. 364 * @return A Collection of the Patterns that were found from the lists. 365 */ 366 private Collection< Pattern > parseWordList( final Page source, final String list ) { 367 final ArrayList< Pattern > compiledpatterns = new ArrayList<>(); 368 369 if( list != null ) { 370 final StringTokenizer tok = new StringTokenizer( list, " \t\n" ); 371 372 while( tok.hasMoreTokens() ) { 373 final String pattern = tok.nextToken(); 374 375 try { 376 compiledpatterns.add( m_compiler.compile( pattern ) ); 377 } catch( final MalformedPatternException e ) { 378 LOG.debug( "Malformed spam filter pattern " + pattern ); 379 source.setAttribute("error", "Malformed spam filter pattern " + pattern); 380 } 381 } 382 } 383 384 return compiledpatterns; 385 } 386 387 /** 388 * Takes a MT-Blacklist -formatted blacklist and returns a list of compiled Pattern objects. 389 * 390 * @param list list of patterns. 391 * @return The parsed blacklist patterns. 392 */ 393 private Collection< Pattern > parseBlacklist( final String list ) { 394 final ArrayList< Pattern > compiledpatterns = new ArrayList<>(); 395 396 if( list != null ) { 397 try { 398 final BufferedReader in = new BufferedReader( new StringReader(list) ); 399 String line; 400 while( (line = in.readLine() ) != null ) { 401 line = line.trim(); 402 if( line.isEmpty() ) continue; // Empty line 403 if( line.startsWith("#") ) continue; // It's a comment 404 405 int ws = line.indexOf( ' ' ); 406 if( ws == -1 ) ws = line.indexOf( '\t' ); 407 if( ws != -1 ) line = line.substring( 0, ws ); 408 409 try { 410 compiledpatterns.add( m_compiler.compile( line ) ); 411 } catch( final MalformedPatternException e ) { 412 LOG.debug( "Malformed spam filter pattern " + line ); 413 } 414 } 415 } catch( final IOException e ) { 416 LOG.info( "Could not read patterns; returning what I got" , e ); 417 } 418 } 419 420 return compiledpatterns; 421 } 422 423 /** 424 * Takes a single page change and performs a load of tests on the content change. An admin can modify anything. 425 * 426 * @param context page Context 427 * @param change page change 428 * @throws RedirectException spam filter rejects the page change. 429 */ 430 private synchronized void checkSinglePageChange(final Context context, final Change change ) 431 throws RedirectException { 432 final HttpServletRequest req = context.getHttpRequest(); 433 434 if( req != null ) { 435 final String addr = HttpUtil.getRemoteAddress( req ); 436 int hostCounter = 0; 437 int changeCounter = 0; 438 439 LOG.debug( "Change is " + change.m_change ); 440 441 final long time = System.currentTimeMillis() - 60*1000L; // 1 minute 442 443 for( final Iterator< Host > i = m_lastModifications.iterator(); i.hasNext(); ) { 444 final Host host = i.next(); 445 446 // Check if this item is invalid 447 if( host.getAddedTime() < time ) { 448 LOG.debug( "Removed host " + host.getAddress() + " from modification queue (expired)" ); 449 i.remove(); 450 continue; 451 } 452 453 // Check if this IP address has been seen before 454 if( host.getAddress().equals( addr ) ) { 455 hostCounter++; 456 } 457 458 // Check, if this change has been seen before 459 if( host.getChange() != null && host.getChange().equals( change ) ) { 460 changeCounter++; 461 } 462 } 463 464 // Now, let's check against the limits. 465 if( hostCounter >= m_limitSinglePageChanges ) { 466 final Host host = new Host( addr, null ); 467 m_temporaryBanList.add( host ); 468 469 final String uid = log( context, REJECT, REASON_TOO_MANY_MODIFICATIONS, change.m_change ); 470 LOG.info( "SPAM:TooManyModifications (" + uid + "). Added host " + addr + " to temporary ban list for doing too many modifications/minute" ); 471 checkStrategy( context, "Herb says you look like a spammer, and I trust Herb! (Incident code " + uid + ")" ); 472 } 473 474 if( changeCounter >= m_limitSimilarChanges ) { 475 final Host host = new Host( addr, null ); 476 m_temporaryBanList.add( host ); 477 478 final String uid = log( context, REJECT, REASON_SIMILAR_MODIFICATIONS, change.m_change ); 479 LOG.info( "SPAM:SimilarModifications (" + uid + "). Added host " + addr + " to temporary ban list for doing too many similar modifications" ); 480 checkStrategy( context, "Herb says you look like a spammer, and I trust Herb! (Incident code "+uid+")"); 481 } 482 483 // Calculate the number of links in the addition. 484 String tstChange = change.toString(); 485 int urlCounter = 0; 486 while( m_matcher.contains( tstChange,m_urlPattern ) ) { 487 final MatchResult m = m_matcher.getMatch(); 488 tstChange = tstChange.substring( m.endOffset(0) ); 489 urlCounter++; 490 } 491 492 if( urlCounter > m_maxUrls ) { 493 final Host host = new Host( addr, null ); 494 m_temporaryBanList.add( host ); 495 496 final String uid = log( context, REJECT, REASON_TOO_MANY_URLS, change.toString() ); 497 LOG.info( "SPAM:TooManyUrls (" + uid + "). Added host " + addr + " to temporary ban list for adding too many URLs" ); 498 checkStrategy( context, "Herb says you look like a spammer, and I trust Herb! (Incident code " + uid + ")" ); 499 } 500 501 // Check bot trap 502 checkBotTrap( context, change ); 503 504 // Check UTF-8 mangling 505 checkUTF8( context, change ); 506 507 // Do Akismet check. This is good to be the last, because this is the most expensive operation. 508 checkAkismet( context, change ); 509 510 m_lastModifications.add( new Host( addr, change ) ); 511 } 512 } 513 514 515 /** 516 * Checks against the akismet system. 517 * 518 * @param context page Context 519 * @throws RedirectException spam filter rejects the page change. 520 */ 521 private void checkAkismet( final Context context, final Change change ) throws RedirectException { 522 if( m_akismetAPIKey != null ) { 523 if( m_akismet == null ) { 524 LOG.info( "Initializing Akismet spam protection." ); 525 String fullPageUrl = context.getHttpRequest().getRequestURL().toString(); 526 String fragment = context.getEngine().getBaseURL(); 527 fullPageUrl = fullPageUrl.substring(0, fullPageUrl.indexOf(fragment) + fragment.length()); 528 m_akismet = new Akismet( m_akismetAPIKey, fullPageUrl ); 529 530 if( !m_akismet.verifyKey() ) { 531 LOG.error( "Akismet API key cannot be verified. Please check your config." ); 532 m_akismetAPIKey = null; 533 m_akismet = null; 534 } 535 } 536 537 final HttpServletRequest req = context.getHttpRequest(); 538 539 // Akismet will mark all empty statements as spam, so we'll just ignore them. 540 if( change.m_adds == 0 && change.m_removals > 0 ) { 541 return; 542 } 543 544 if( req != null && m_akismet != null ) { 545 LOG.debug( "Calling Akismet to check for spam..." ); 546 547 final StopWatch sw = new StopWatch(); 548 sw.start(); 549 550 final String ipAddress = HttpUtil.getRemoteAddress( req ); 551 final String userAgent = req.getHeader( "User-Agent" ); 552 final String referrer = req.getHeader( "Referer"); 553 final String permalink = context.getViewURL( context.getPage().getName() ); 554 final String commentType = context.getRequestContext().equals( ContextEnum.PAGE_COMMENT.getRequestContext() ) ? "comment" : "edit"; 555 final String commentAuthor = context.getCurrentUser().getName(); 556 final String commentAuthorEmail = null; 557 final String commentAuthorURL = null; 558 AkismetComment comment = new AkismetComment(ipAddress, userAgent); 559 comment.setAuthor(commentAuthor); 560 comment.setAuthorEmail(commentAuthorEmail); 561 comment.setAuthorUrl(commentAuthorURL); 562 comment.setContent(change.toString()); 563 comment.setPermalink(permalink); 564 comment.setReferrer(referrer); 565 comment.setType(commentType); 566 567 final boolean isSpam = m_akismet.checkComment(comment); 568 569 sw.stop(); 570 LOG.debug( "Akismet request done in: " + sw ); 571 572 if( isSpam ) { 573 // Host host = new Host( ipAddress, null ); 574 // m_temporaryBanList.add( host ); 575 576 final String uid = log( context, REJECT, REASON_AKISMET, change.toString() ); 577 LOG.info( "SPAM:Akismet (" + uid + "). Akismet thinks this change is spam; added host to temporary ban list." ); 578 checkStrategy( context, "Akismet tells Herb you're a spammer, Herb trusts Akismet, and I trust Herb! (Incident code " + uid + ")" ); 579 } 580 } 581 } 582 } 583 584 /** 585 * Returns a static string which can be used to detect spambots which just wildly fill in all the fields. 586 * 587 * @return A string 588 */ 589 public static String getBotFieldName() { 590 return "submit_auth"; 591 } 592 593 /** 594 * This checks whether an invisible field is available in the request, and whether it's contents are suspected spam. 595 * 596 * @param context page Context 597 * @param change page change 598 * @throws RedirectException spam filter rejects the page change. 599 */ 600 private void checkBotTrap( final Context context, final Change change ) throws RedirectException { 601 final HttpServletRequest request = context.getHttpRequest(); 602 if( request != null ) { 603 final String unspam = request.getParameter( getBotFieldName() ); 604 if( unspam != null && !unspam.isEmpty() ) { 605 final String uid = log( context, REJECT, REASON_BOT_TRAP, change.toString() ); 606 607 LOG.info( "SPAM:BotTrap (" + uid + "). Wildly behaving bot detected." ); 608 checkStrategy( context, "Spamming attempt detected. (Incident code " + uid + ")" ); 609 } 610 } 611 } 612 613 private void checkUTF8( final Context context, final Change change ) throws RedirectException { 614 final HttpServletRequest request = context.getHttpRequest(); 615 if( request != null ) { 616 final String utf8field = request.getParameter( "encodingcheck" ); 617 if( utf8field != null && !utf8field.equals( "\u3041" ) ) { 618 final String uid = log( context, REJECT, REASON_UTF8_TRAP, change.toString() ); 619 620 LOG.info( "SPAM:UTF8Trap (" + uid + "). Wildly posting dumb bot detected." ); 621 checkStrategy( context, "Spamming attempt detected. (Incident code " + uid + ")" ); 622 } 623 } 624 } 625 626 /** Goes through the ban list and cleans away any host which has expired from it. */ 627 private synchronized void cleanBanList() { 628 final long now = System.currentTimeMillis(); 629 for( final Iterator< Host > i = m_temporaryBanList.iterator(); i.hasNext(); ) { 630 final Host host = i.next(); 631 632 if( host.getReleaseTime() < now ) { 633 LOG.debug( "Removed host " + host.getAddress() + " from temporary ban list (expired)" ); 634 i.remove(); 635 } 636 } 637 } 638 639 /** 640 * Checks the ban list if the IP address of the changer is already on it. 641 * 642 * @param context page context 643 * @throws RedirectException spam filter rejects the page change. 644 */ 645 private void checkBanList( final Context context, final Change change ) throws RedirectException { 646 final HttpServletRequest req = context.getHttpRequest(); 647 648 if( req != null ) { 649 final String remote = HttpUtil.getRemoteAddress(req); 650 final long now = System.currentTimeMillis(); 651 652 for( final Host host : m_temporaryBanList ) { 653 if( host.getAddress().equals( remote ) ) { 654 final long timeleft = ( host.getReleaseTime() - now ) / 1000L; 655 656 log( context, REJECT, REASON_IP_BANNED_TEMPORARILY, change.m_change ); 657 checkStrategy( context, 658 "You have been temporarily banned from modifying this wiki. (" + timeleft + " seconds of ban left)" ); 659 } 660 } 661 } 662 } 663 664 /** 665 * If the spam filter notices changes in the black list page, it will refresh them automatically. 666 * 667 * @param context associated WikiContext 668 */ 669 private void refreshBlacklists( final Context context ) { 670 try { 671 boolean rebuild = false; 672 673 // Rebuild, if the spam words page, the attachment or the IP ban page has changed since. 674 final Page sourceSpam = context.getEngine().getManager( PageManager.class ).getPage( m_forbiddenWordsPage ); 675 if( sourceSpam != null ) { 676 if( m_spamPatterns == null || m_spamPatterns.isEmpty() || sourceSpam.getLastModified().after( m_lastRebuild ) ) { 677 rebuild = true; 678 } 679 } 680 681 final Attachment att = context.getEngine().getManager( AttachmentManager.class ).getAttachmentInfo( context, m_blacklist ); 682 if( att != null ) { 683 if( m_spamPatterns == null || m_spamPatterns.isEmpty() || att.getLastModified().after( m_lastRebuild ) ) { 684 rebuild = true; 685 } 686 } 687 688 final Page sourceIPs = context.getEngine().getManager( PageManager.class ).getPage( m_forbiddenIPsPage ); 689 if( sourceIPs != null ) { 690 if( m_IPPatterns == null || m_IPPatterns.isEmpty() || sourceIPs.getLastModified().after( m_lastRebuild ) ) { 691 rebuild = true; 692 } 693 } 694 695 // Do the actual rebuilding. For simplicity's sake, we always rebuild the complete filter list regardless of what changed. 696 if( rebuild ) { 697 m_lastRebuild = new Date(); 698 m_spamPatterns = parseWordList( sourceSpam, ( sourceSpam != null ) ? sourceSpam.getAttribute( LISTVAR ) : null ); 699 700 LOG.info( "Spam filter reloaded - recognizing " + m_spamPatterns.size() + " patterns from page " + m_forbiddenWordsPage ); 701 702 m_IPPatterns = parseWordList( sourceIPs, ( sourceIPs != null ) ? sourceIPs.getAttribute( LISTIPVAR ) : null ); 703 LOG.info( "IP filter reloaded - recognizing " + m_IPPatterns.size() + " patterns from page " + m_forbiddenIPsPage ); 704 705 if( att != null ) { 706 final InputStream in = context.getEngine().getManager( AttachmentManager.class ).getAttachmentStream(att); 707 final StringWriter out = new StringWriter(); 708 FileUtil.copyContents( new InputStreamReader( in, StandardCharsets.UTF_8 ), out ); 709 final Collection< Pattern > blackList = parseBlacklist( out.toString() ); 710 LOG.info( "...recognizing additional " + blackList.size() + " patterns from blacklist " + m_blacklist ); 711 m_spamPatterns.addAll( blackList ); 712 } 713 } 714 } catch( final IOException ex ) { 715 LOG.info( "Unable to read attachment data, continuing...", ex ); 716 } catch( final ProviderException ex ) { 717 LOG.info( "Failed to read spam filter attachment, continuing...", ex ); 718 } 719 } 720 721 /** 722 * Does a check against a known pattern list. 723 * 724 * @param context page Context 725 * @param change page change 726 * @throws RedirectException spam filter rejects the page change. 727 */ 728 private void checkPatternList( final Context context, final Change change ) throws RedirectException { 729 // If we have no spam patterns defined, or we're trying to save the page containing the patterns, just return. 730 if( m_spamPatterns == null || context.getPage().getName().equals( m_forbiddenWordsPage ) ) { 731 return; 732 } 733 734 String ch = change.toString(); 735 if( context.getHttpRequest() != null ) { 736 ch += HttpUtil.getRemoteAddress( context.getHttpRequest() ); 737 } 738 739 for( final Pattern p : m_spamPatterns ) { 740 // LOG.debug("Attempting to match page contents with "+p.getPattern()); 741 742 if( m_matcher.contains( ch, p ) ) { 743 // Spam filter has a match. 744 final String uid = log( context, REJECT, REASON_REGEXP + "(" + p.getPattern() + ")", ch ); 745 746 LOG.info( "SPAM:Regexp (" + uid + "). Content matches the spam filter '" + p.getPattern() + "'" ); 747 checkStrategy( context, "Herb says '" + p.getPattern() + "' is a bad spam word and I trust Herb! (Incident code " + uid + ")" ); 748 } 749 } 750 } 751 752 753 /** 754 * Does a check against a pattern list of IPs. 755 * 756 * @param context page context 757 * @throws RedirectException spam filter rejects the page change. 758 */ 759 private void checkIPList( final Context context ) throws RedirectException { 760 // If we have no IP patterns defined, or we're trying to save the page containing the IP patterns, just return. 761 if( m_IPPatterns == null || context.getPage().getName().equals( m_forbiddenIPsPage ) ) { 762 return; 763 } 764 765 final String remoteIP = HttpUtil.getRemoteAddress( context.getHttpRequest() ); 766 LOG.info("Attempting to match remoteIP " + remoteIP + " against " + m_IPPatterns.size() + " patterns"); 767 768 for( final Pattern p : m_IPPatterns ) { 769 LOG.debug("Attempting to match remoteIP with " + p.getPattern()); 770 771 if( m_matcher.contains( remoteIP, p ) ) { 772 773 // IP filter has a match. 774 // 775 final String uid = log( context, REJECT, REASON_IP_BANNED_PERMANENTLY + "(" + p.getPattern() + ")", remoteIP ); 776 777 LOG.info( "SPAM:IPBanList (" + uid + "). remoteIP matches the IP filter '" + p.getPattern() + "'" ); 778 checkStrategy( context, "Herb says '" + p.getPattern() + "' is a banned IP and I trust Herb! (Incident code " + uid + ")" ); 779 } 780 } 781 } 782 783 private void checkPatternList( final Context context, final String change ) throws RedirectException { 784 final Change c = new Change(); 785 c.m_change = change; 786 checkPatternList( context, c ); 787 } 788 789 /** 790 * Creates a simple text string describing the added content. 791 * 792 * @param context page context 793 * @param newText added content 794 * @return Empty string, if there is no change. 795 */ 796 private static Change getChange( final Context context, final String newText ) { 797 final Page page = context.getPage(); 798 final StringBuffer change = new StringBuffer(); 799 final Engine engine = context.getEngine(); 800 // Get current page version 801 802 final Change ch = new Change(); 803 804 try { 805 final String oldText = engine.getManager( PageManager.class ).getPureText( page.getName(), WikiProvider.LATEST_VERSION ); 806 Patch<String> patch = DiffUtils.diffInline(oldText, newText); 807 808 809 if( patch == null ) { 810 return ch; 811 } 812 int lineNumber = 1; 813 int currentOriginalLine = 0; 814 int currentModifiedLine = 0; 815 816 for (AbstractDelta<String> delta : patch.getDeltas()) { 817 int originalPosition = delta.getSource().getPosition(); 818 int modifiedPosition = delta.getTarget().getPosition(); 819 820 // Output unchanged lines before the delta 821 while (currentOriginalLine < originalPosition && currentModifiedLine < modifiedPosition) { 822 lineNumber++; 823 currentOriginalLine++; 824 currentModifiedLine++; 825 } 826 827 List<String> originalLines = delta.getSource().getLines(); 828 List<String> revisedLines = delta.getTarget().getLines(); 829 830 for (String line : originalLines) { 831 change.append("- " + lineNumber + ": " + line + "\r\n"); 832 ch.m_removals++; 833 lineNumber++; 834 currentOriginalLine++; 835 } 836 837 for (String line : revisedLines) { 838 change.append("+ " + lineNumber + ": " + line + "\r\n"); 839 lineNumber++; 840 currentModifiedLine++; 841 ch.m_adds++; 842 843 } 844 } 845 846 } catch (final Exception e) { 847 LOG.error("Diff failed", e); 848 } 849 850 // Don't forget to include the change note, too 851 final String changeNote = page.getAttribute( Page.CHANGENOTE ); 852 if( changeNote != null ) { 853 change.append( "\r\n" ); 854 change.append( changeNote ); 855 } 856 857 // And author as well 858 if( page.getAuthor() != null ) { 859 change.append( "\r\n" ).append( page.getAuthor() ); 860 } 861 862 ch.m_change = change.toString(); 863 return ch; 864 } 865 866 /** 867 * Returns true, if this user should be ignored. For example, admin users. 868 * 869 * @param context page context 870 * @return True, if this user should be ignored. 871 */ 872 private boolean ignoreThisUser( final Context context ) { 873 if( context.hasAdminPermissions() ) { 874 return true; 875 } 876 877 final List< String > groups = Arrays.asList( m_allowedGroups ); 878 if( Arrays.stream( context.getWikiSession().getRoles() ).anyMatch( role -> groups.contains( role.getName() ) ) ) { 879 return true; 880 } 881 882 if( m_ignoreAuthenticated && context.getWikiSession().isAuthenticated() ) { 883 return true; 884 } 885 886 return context.getVariable("captcha") != null; 887 } 888 889 /** 890 * Returns a random string of six uppercase characters. 891 * 892 * @return A random string 893 */ 894 private static String getUniqueID() { 895 final StringBuilder sb = new StringBuilder(); 896 for( int i = 0; i < 6; i++ ) { 897 final char x = ( char )( 'A' + RANDOM.nextInt( 26 ) ); 898 sb.append( x ); 899 } 900 901 return sb.toString(); 902 } 903 904 /** 905 * Returns a page to which we shall redirect, based on the current value of the "captcha" parameter. 906 * 907 * @param ctx WikiContext 908 * @return An URL to redirect to 909 */ 910 private String getRedirectPage( final Context ctx ) { 911 912 return ctx.getURL( ContextEnum.PAGE_VIEW.getRequestContext(), m_errorPage ); 913 } 914 915 /** 916 * Checks whether the UserProfile matches certain checks. 917 * 918 * @param profile The profile to check 919 * @param context The WikiContext 920 * @return False, if this userprofile is suspect and should not be allowed to be added. 921 * @since 2.6.1 922 */ 923 public boolean isValidUserProfile( final Context context, final UserProfile profile ) { 924 try { 925 checkPatternList( context, profile.getEmail() ); 926 checkPatternList( context, profile.getFullname() ); 927 checkPatternList( context, profile.getLoginName() ); 928 } catch( final RedirectException e ) { 929 LOG.info("Detected attempt to create a spammer user account (see above for rejection reason)"); 930 return false; 931 } 932 933 return true; 934 } 935 936 /** 937 * This method is used to calculate an unique code when submitting the page to detect edit conflicts. 938 * It currently incorporates the last-modified date of the page, and the IP address of the submitter. 939 * 940 * @param page The WikiPage under edit 941 * @param request The HTTP Request 942 * @since 2.6 943 * @return A hash value for this page and session 944 */ 945 public static String getSpamHash( final Page page, final HttpServletRequest request ) { 946 long lastModified = 0; 947 948 if( page.getLastModified() != null ) { 949 lastModified = page.getLastModified().getTime(); 950 } 951 final long remote = HttpUtil.getRemoteAddress( request ).hashCode(); 952 953 return Long.toString( lastModified ^ remote ); 954 } 955 956 /** 957 * Returns the name of the hash field to be used in this request. The value is unique per session, and once 958 * the session has expired, you cannot edit anymore. 959 * 960 * @param request The page request 961 * @return The name to be used in the hash field 962 * @since 2.6 963 */ 964 public static String getHashFieldName( final HttpServletRequest request ) { 965 String hash = null; 966 967 if( request.getSession() != null ) { 968 hash = ( String )request.getSession().getAttribute( "_hash" ); 969 970 if( hash == null ) { 971 hash = c_hashName; 972 request.getSession().setAttribute( "_hash", hash ); 973 } 974 } 975 976 if( c_hashName == null || c_lastUpdate < ( System.currentTimeMillis() - HASH_DELAY * 60 * 60 * 1000 ) ) { 977 c_hashName = getUniqueID().toLowerCase(); 978 c_lastUpdate = System.currentTimeMillis(); 979 } 980 981 return hash != null ? hash : c_hashName; 982 } 983 984 985 /** 986 * This method checks if the hash value is still valid, i.e. if it exists at all. This can occur in two cases: 987 * either this is a spam bot which is not adaptive, or it is someone who has been editing one page for too long, 988 * and their session has expired. 989 * <p> 990 * This method puts a redirect to the http response field to page "SessionExpired" and logs the incident in 991 * the spam log (it may or may not be spam, but it's rather likely that it is). 992 * 993 * @param context The WikiContext 994 * @param pageContext The JSP PageContext. 995 * @return True, if hash is okay. False, if hash is not okay, and you need to redirect. 996 * @throws IOException If redirection fails 997 * @since 2.6 998 */ 999 public static boolean checkHash( final Context context, final PageContext pageContext ) throws IOException { 1000 final String hashName = getHashFieldName( (HttpServletRequest)pageContext.getRequest() ); 1001 if( pageContext.getRequest().getParameter(hashName) == null ) { 1002 if( pageContext.getAttribute( hashName ) == null ) { 1003 final Change change = getChange( context, EditorManager.getEditedText( pageContext ) ); 1004 log( context, REJECT, "MissingHash", change.m_change ); 1005 1006 final String redirect = context.getURL( ContextEnum.PAGE_VIEW.getRequestContext(),"SessionExpired" ); 1007 ( ( HttpServletResponse )pageContext.getResponse() ).sendRedirect( redirect ); 1008 return false; 1009 } 1010 } 1011 1012 return true; 1013 } 1014 1015 /** 1016 * This helper method adds all the input fields to your editor that the SpamFilter requires 1017 * to check for spam. This <i>must</i> be in your editor form if you intend to use the SpamFilter. 1018 * 1019 * @param pageContext The PageContext 1020 * @return A HTML string which contains input fields for the SpamFilter. 1021 */ 1022 public static String insertInputFields( final PageContext pageContext ) { 1023 final Context ctx = Context.findContext( pageContext ); 1024 final Engine engine = ctx.getEngine(); 1025 final StringBuilder sb = new StringBuilder(); 1026 if( engine.getContentEncoding().equals( StandardCharsets.UTF_8 ) ) { 1027 sb.append( "<input name='encodingcheck' type='hidden' value='\u3041' />\n" ); 1028 } 1029 1030 return sb.toString(); 1031 } 1032 1033 /** 1034 * A local class for storing host information. 1035 */ 1036 private class Host { 1037 1038 private final long m_addedTime = System.currentTimeMillis(); 1039 private final long m_releaseTime; 1040 private final String m_address; 1041 private final Change m_change; 1042 1043 public String getAddress() { 1044 return m_address; 1045 } 1046 1047 public long getReleaseTime() { 1048 return m_releaseTime; 1049 } 1050 1051 public long getAddedTime() { 1052 return m_addedTime; 1053 } 1054 1055 public Change getChange() { 1056 return m_change; 1057 } 1058 1059 public Host( final String ipaddress, final Change change ) { 1060 m_address = ipaddress; 1061 m_change = change; 1062 m_releaseTime = System.currentTimeMillis() + m_banTime * 60 * 1000L; 1063 } 1064 1065 } 1066 1067 private static class Change { 1068 1069 public String m_change; 1070 public int m_adds; 1071 public int m_removals; 1072 1073 @Override 1074 public String toString() { 1075 return m_change; 1076 } 1077 1078 @Override 1079 public boolean equals( final Object o ) { 1080 if( o instanceof Change ) { 1081 return m_change.equals( ( ( Change )o ).m_change ); 1082 } 1083 return false; 1084 } 1085 1086 @Override 1087 public int hashCode() { 1088 return m_change.hashCode() + 17; 1089 } 1090 1091 } 1092 1093}