|
1 | 1 | /** |
2 | | - * @(#)ServletUtils.java 0.01 22/02/2011 |
3 | | - * Copyright (C) 2011 - 2023 MER-C |
| 2 | + * @(#)ServletUtils.java 0.02 13/04/2025 |
| 3 | + * Copyright (C) 2011 - 2025 MER-C |
4 | 4 | * |
5 | 5 | * This program is free software: you can redistribute it and/or modify |
6 | 6 | * it under the terms of the GNU Affero General Public License as |
|
18 | 18 |
|
19 | 19 | package org.wikipedia.servlets; |
20 | 20 |
|
| 21 | +import java.io.*; |
| 22 | +import java.math.BigInteger; |
21 | 23 | import java.nio.charset.StandardCharsets; |
22 | 24 | import java.net.URLEncoder; |
23 | | -import java.util.Objects; |
| 25 | +import java.security.*; |
| 26 | +import java.time.OffsetDateTime; |
| 27 | +import java.util.*; |
| 28 | + |
| 29 | +import jakarta.servlet.http.*; |
24 | 30 |
|
25 | 31 | /** |
26 | 32 | * Common servlet code so that I can maintain it easier. |
27 | 33 | * @author MER-C |
| 34 | + * @since 0.02 |
28 | 35 | */ |
29 | 36 | public class ServletUtils |
30 | 37 | { |
@@ -161,4 +168,108 @@ public static String generatePagination(String urlbase, int current, int amount, |
161 | 168 | sb.append("</a>"); |
162 | 169 | return sb.toString(); |
163 | 170 | } |
| 171 | + |
| 172 | + /** |
| 173 | + * Presents a SHA-256 proof of work CAPTCHA. To pass the CAPTCHA, the |
| 174 | + * client needs to compute a nonce such that |
| 175 | + * sha256(nonce + timestamp + selected concatenated HTTP parameters) begins |
| 176 | + * with some quantity of zeros (difficulty is customisable, default 3). |
| 177 | + * A CAPTCHA page is shown when the user submits a request. When complete, |
| 178 | + * the CAPTCHA page redirects to the expected results. A solved CAPTCHA |
| 179 | + * adds URL parameters <code>powans</code> (the solution), <code>powts</code>, |
| 180 | + * <code>nonce</code> and <code>powdif</code> (the difficulty). The CAPTCHA |
| 181 | + * expires after five minutes. |
| 182 | + * |
| 183 | + * @param req a servlet request |
| 184 | + * @param response the corresponding response |
| 185 | + * @param params the request parameters to concatenate to form the challenge |
| 186 | + * string. The challenge string cannot contain new lines. |
| 187 | + * @param captcha_string_nonce a nonce, for Content Security Policy purposes, |
| 188 | + * to protect an inline script that defines the challenge string only |
| 189 | + * @return whether to continue servlet execution |
| 190 | + * @throws IOException if a network error occurs |
| 191 | + * @see captcha.js |
| 192 | + * @since 0.02 |
| 193 | + */ |
| 194 | + public static boolean showCaptcha(HttpServletRequest req, HttpServletResponse response, List<String> params, String captcha_string_nonce) throws IOException |
| 195 | + { |
| 196 | + // no captcha for the initial input |
| 197 | + if (req.getParameterMap().isEmpty()) |
| 198 | + return true; |
| 199 | + |
| 200 | + // TODO: captcha.js does not propagate POST parameters |
| 201 | + |
| 202 | + PrintWriter out = response.getWriter(); |
| 203 | + String answer = req.getParameter("powans"); |
| 204 | + String timestamp = req.getParameter("powts"); |
| 205 | + String nonce = req.getParameter("nonce"); |
| 206 | + String difficulty = req.getParameter("powdif"); |
| 207 | + |
| 208 | + StringBuilder paramstr = new StringBuilder(); |
| 209 | + for (String param : params) |
| 210 | + paramstr.append(req.getParameter(param)); |
| 211 | + String challenge = sanitizeForAttribute(paramstr.toString()); |
| 212 | + String tohash = nonce + timestamp + challenge; |
| 213 | + |
| 214 | + // captcha not attempted, show CAPTCHA screen |
| 215 | + if (answer == null && timestamp == null && nonce == null && difficulty == null) |
| 216 | + { |
| 217 | + out.println(""" |
| 218 | + <!doctype html> |
| 219 | + <html> |
| 220 | + <head> |
| 221 | + <title>CAPTCHA</title>"""); |
| 222 | + out.println("<script nonce=\"" + captcha_string_nonce + "\">"); |
| 223 | + out.println(" window.chl = \"" + challenge + "\";"); |
| 224 | + out.println(""" |
| 225 | + </script> |
| 226 | + <script src="captcha.js" defer></script> |
| 227 | + </head> |
| 228 | + <body> |
| 229 | + <h1>Verifying you are not a bot</h1> |
| 230 | + <p>You should be redirected to your results shortly. Unfortunately JavaScript is required for this to work. |
| 231 | + </body> |
| 232 | + </html> |
| 233 | + """); |
| 234 | + return false; |
| 235 | + } |
| 236 | + // incomplete parameters = fail |
| 237 | + else if (answer == null || timestamp == null || nonce == null || difficulty == null) |
| 238 | + { |
| 239 | + response.setStatus(403); |
| 240 | + out.println("Incomplete CAPTCHA parameters"); |
| 241 | + return false; |
| 242 | + } |
| 243 | + |
| 244 | + // not recent = fail |
| 245 | + OffsetDateTime odt = OffsetDateTime.parse(timestamp); |
| 246 | + if (OffsetDateTime.now().minusMinutes(5).isAfter(odt)) |
| 247 | + { |
| 248 | + response.setStatus(403); |
| 249 | + out.println("CAPTCHA expired"); |
| 250 | + return false; |
| 251 | + } |
| 252 | + |
| 253 | + try |
| 254 | + { |
| 255 | + MessageDigest digest = MessageDigest.getInstance("SHA-256"); |
| 256 | + byte[] hash = digest.digest(tohash.getBytes(StandardCharsets.UTF_8)); |
| 257 | + String expected = "%064x".formatted(new BigInteger(1, hash)); |
| 258 | + String prefix = "0".repeat(Integer.parseInt(difficulty)); |
| 259 | + if (answer.startsWith(prefix) && expected.equals(answer)) |
| 260 | + return true; |
| 261 | + |
| 262 | + response.setStatus(403); |
| 263 | + out.println("CAPTCHA failed"); |
| 264 | + return false; |
| 265 | + |
| 266 | + } |
| 267 | + catch (NoSuchAlgorithmException ex) |
| 268 | + { |
| 269 | + response.setStatus(302); |
| 270 | + response.setHeader("Location", "/timeout.html"); |
| 271 | + response.getWriter().close(); |
| 272 | + return false; |
| 273 | + } |
| 274 | + } |
164 | 275 | } |
0 commit comments