Skip to content

Commit 8df3883

Browse files
committed
Add a basic proof-of-work CAPTCHA.
1 parent 6d42d24 commit 8df3883

File tree

9 files changed

+247
-8
lines changed

9 files changed

+247
-8
lines changed

src/org/wikipedia/servlets/ServletUtils.java

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
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
44
*
55
* This program is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU Affero General Public License as
@@ -18,13 +18,20 @@
1818

1919
package org.wikipedia.servlets;
2020

21+
import java.io.*;
22+
import java.math.BigInteger;
2123
import java.nio.charset.StandardCharsets;
2224
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.*;
2430

2531
/**
2632
* Common servlet code so that I can maintain it easier.
2733
* @author MER-C
34+
* @since 0.02
2835
*/
2936
public class ServletUtils
3037
{
@@ -161,4 +168,108 @@ public static String generatePagination(String urlbase, int current, int amount,
161168
sb.append("</a>");
162169
return sb.toString();
163170
}
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+
}
164275
}

src/org/wikipedia/servlets/extlinkchecker.jsp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
-->
99
<%@ include file="security.jspf" %>
1010
<%
11+
if (!ServletUtils.showCaptcha(request, response, List.of("title", "wiki"), captcha_script_nonce))
12+
throw new SkipPageException();
1113
request.setAttribute("toolname", "External link checker");
1214
1315
String wiki = ServletUtils.sanitizeForAttributeOrDefault(request.getParameter("wiki"), "en.wikipedia.org");

src/org/wikipedia/servlets/linksearch.jsp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
-->
99
<%@ include file="security.jspf" %>
1010
<%
11+
if (!ServletUtils.showCaptcha(request, response, List.of("link"), captcha_script_nonce))
12+
throw new SkipPageException();
1113
request.setAttribute("toolname", "Cross-wiki linksearch");
1214
request.setAttribute("scripts", new String[] { "common.js", "XWikiLinksearch.js" });
1315
int limit = 500;

src/org/wikipedia/servlets/nppcheck.jsp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
<%@ include file="security.jspf" %>
1010
<%@ include file="datevalidate.jspf" %>
1111
<%
12-
request.setAttribute("toolname", "NPP/AFC checker");
12+
if (!ServletUtils.showCaptcha(request, response, List.of("username"), captcha_script_nonce))
13+
throw new SkipPageException(); request.setAttribute("toolname", "NPP/AFC checker");
1314
1415
String username = ServletUtils.sanitizeForAttribute(request.getParameter("username"));
1516
NPPCheck.Mode mode = NPPCheck.Mode.fromString(request.getParameter("mode"));

src/org/wikipedia/servlets/prefixcontribs.jsp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
-->
99
<%@ include file="security.jspf" %>
1010
<%
11+
if (!ServletUtils.showCaptcha(request, response, List.of("prefix"), captcha_script_nonce))
12+
throw new SkipPageException();
1113
request.setAttribute("toolname", "Prefix contributions");
1214
request.setAttribute("earliest_default", LocalDate.now(ZoneOffset.UTC).minusDays(7));
1315

src/org/wikipedia/servlets/security.jspf

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<%@ page import="java.io.*" %>
2020
<%@ page import="java.net.*" %>
2121
<%@ page import="java.nio.charset.StandardCharsets" %>
22+
<%@ page import="java.security.SecureRandom" %>
2223
<%@ page import="java.util.*" %>
2324
<%@ page import="java.util.stream.*" %>
2425
<%@ page import="java.time.*" %>
@@ -46,16 +47,22 @@
4647
WMFWikiFarm sessions = WMFWikiFarm.instance();
4748
sessions.setInitializer(wiki_ -> wiki_.setMaxLag(-1));
4849
response.setCharacterEncoding("UTF-8");
50+
51+
// compute nonce for inline script setting captcha challenge string
52+
SecureRandom sr = new SecureRandom();
53+
byte[] barrtemp = new byte[32];
54+
sr.nextBytes(barrtemp);
55+
String captcha_script_nonce = new String(Base64.getEncoder().encode(barrtemp));
4956

5057
// Set security headers
5158

5259
// Enable HSTS (force HTTPS)
5360
response.setHeader("Strict-Transport-Security", "max-age=31536000");
5461
response.setHeader("Content-Security-Policy",
5562
"frame-ancestors 'none'; " + // disable framing
56-
"default-src 'none'; " + // disable everything by default
57-
"script-src 'self'; " + // allow only scripts from this domain
58-
"style-src 'self'"); // allow only stylesheets from this domain
63+
"default-src 'none'; " + // disable everything by default
64+
"script-src 'self' 'nonce-" + captcha_script_nonce + "'; " + // allow only scripts from this domain
65+
"style-src 'self'"); // allow only stylesheets from this domain
5966
// disable the Referer header
6067
response.setHeader("Referrer-Policy", "no-referrer");
6168
%>

src/org/wikipedia/servlets/spamarchivesearch.jsp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
-->
99
<%@ include file="security.jspf" %>
1010
<%
11+
if (!ServletUtils.showCaptcha(request, response, List.of("query"), captcha_script_nonce))
12+
throw new SkipPageException();
1113
request.setAttribute("toolname", "Spam archive search");
1214
String query = request.getParameter("query");
1315
%>

src/org/wikipedia/servlets/userwatchlist.jsp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
-->
99
<%@ include file="security.jspf" %>
1010
<%
11+
if (!ServletUtils.showCaptcha(request, response, List.of("page"), captcha_script_nonce))
12+
throw new SkipPageException();
1113
request.setAttribute("toolname", "User watchlist");
1214
request.setAttribute("earliest_default", LocalDate.now(ZoneOffset.UTC).minusDays(30));
1315
@@ -26,7 +28,7 @@
2628
boolean newonly = (request.getParameter("newonly") != null);
2729
2830
Wiki enWiki = sessions.sharedSession("en.wikipedia.org");
29-
enWiki.setQueryLimit(30000); // 60 network requests
31+
enWiki.setQueryLimit(20000); // 40 network requests
3032
Users userUtils = Users.of(enWiki);
3133
Revisions revisionUtils = Revisions.of(enWiki);
3234
Pages pageUtils = Pages.of(enWiki);

web/captcha.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Calculates the SHA-256 hash of a string using SubtleCrypto
3+
* and returns it as a hexadecimal string.
4+
*
5+
* @param {string} str - The input string.
6+
* @returns {Promise<string>} A Promise that resolves with the SHA-256 hash as a 64-character hex string.
7+
* @throws {Error} If SubtleCrypto is not available.
8+
*/
9+
async function sha256_hex(str)
10+
{
11+
if (!crypto || !crypto.subtle || !crypto.subtle.digest)
12+
{
13+
throw new Error("SubtleCrypto API not available in this browser/context.");
14+
}
15+
if (typeof TextEncoder === 'undefined')
16+
{
17+
throw new Error("TextEncoder API not available in this browser/context.");
18+
}
19+
20+
// Encode the string into a Uint8Array -> ArrayBuffer
21+
const dataBuffer = new TextEncoder().encode(str);
22+
23+
// Calculate the hash using SubtleCrypto
24+
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
25+
26+
// Convert the ArrayBuffer to a hexadecimal string
27+
const hashArray = Array.from(new Uint8Array(hashBuffer)); // Convert buffer to byte array
28+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // Convert bytes to hex
29+
30+
return hashHex;
31+
}
32+
33+
/**
34+
* Solves a Proof-of-Work (PoW) challenge using SHA-256 via SubtleCrypto.
35+
* Finds a nonce such that the SHA-256 hash of (challenge + nonce)
36+
* starts with a specified number of zero characters (hexadecimal).
37+
*
38+
* THIS FUNCTION IS ASYNCHRONOUS due to SubtleCrypto usage.
39+
*
40+
* @param {string} challenge - A unique string for this specific PoW task.
41+
* @param {number} [difficulty=3] - The required number of leading zero hex characters (0-64).
42+
* Higher numbers are exponentially harder. **Requires tuning for desired runtime.**
43+
* @returns {Promise<{nonce: number, hash: string, duration: number, challenge: string, difficulty: number}>}
44+
* A Promise resolving to an object containing the found nonce, the resulting 64-char hex hash,
45+
* the time taken in milliseconds, the original challenge, and the difficulty used.
46+
* @throws {Error} If the challenge is not a non-empty string, difficulty is invalid,
47+
* or SubtleCrypto/TextEncoder is unavailable.
48+
*/
49+
async function solvePoWCaptchaSHA256(challenge, difficulty = 3)
50+
{
51+
if (typeof challenge !== 'string' || challenge.length === 0)
52+
{
53+
throw new Error("PoW challenge must be a non-empty string.");
54+
}
55+
// SHA-256 produces a 64-character hex string (256 bits)
56+
if (!Number.isInteger(difficulty) || difficulty <= 0 || difficulty > 64)
57+
{
58+
throw new Error("PoW difficulty must be a positive integer between 1 and 64.");
59+
}
60+
// Check for SubtleCrypto availability early
61+
if (!crypto || !crypto.subtle || !crypto.subtle.digest || typeof TextEncoder === 'undefined')
62+
{
63+
throw new Error("Required cryptographic APIs (SubtleCrypto, TextEncoder) not available.");
64+
}
65+
66+
console.log(`Starting SHA-256 PoW solve: Challenge="${challenge}", Difficulty=${difficulty}`);
67+
const startTime = performance.now();
68+
69+
const targetPrefix = '0'.repeat(difficulty);
70+
let nonce = 0;
71+
let hash = '';
72+
let data = '';
73+
74+
// Loop until a valid hash is found
75+
while (true)
76+
{
77+
data = nonce + challenge; // Concatenate challenge and current nonce
78+
hash = await sha256_hex(data); // Calculate the SHA-256 hash (asynchronously)
79+
80+
if (hash.startsWith(targetPrefix))
81+
{
82+
// Solution found!
83+
const endTime = performance.now();
84+
const duration = endTime - startTime;
85+
console.log(`SHA-256 PoW Solved! Nonce: ${nonce}, Hash: ${hash}, Time: ${duration.toFixed(2)} ms`);
86+
87+
return {
88+
nonce: nonce,
89+
hash: hash,
90+
duration: duration,
91+
challenge: challenge,
92+
difficulty: difficulty
93+
};
94+
}
95+
96+
nonce++; // Increment nonce and try again
97+
}
98+
}
99+
100+
// Everything above is courtesy of Gemini 2.5 Pro
101+
const date = new Date().toISOString();
102+
var captcha = solvePoWCaptchaSHA256(date + window.chl);
103+
const temp = new URL(window.location.href);
104+
captcha.then((result) => {
105+
temp.searchParams.set("powans", result.hash);
106+
temp.searchParams.set("nonce", result.nonce);
107+
temp.searchParams.set("powts", date);
108+
temp.searchParams.set("powdif", result.difficulty);
109+
window.location.href = temp.toString();
110+
});

0 commit comments

Comments
 (0)