Skip to content

Commit fd06434

Browse files
committed
Implement utils.testCaptcha and utils.testValidation
1 parent 9f056da commit fd06434

File tree

14 files changed

+207
-17
lines changed

14 files changed

+207
-17
lines changed

src/main/java/smithereen/SmithereenApplication.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,8 @@ public static void main(String[] args){
10241024
options("/uploadAttachmentPhoto", ApiRoutes::apiCallPreflight);
10251025
options("/uploadAlbumPhoto", ApiRoutes::apiCallPreflight);
10261026
options("/uploadAvatar", ApiRoutes::apiCallPreflight);
1027+
get("/captcha/:sid", ApiRoutes::captcha);
1028+
get("/testValidation", ApiRoutes::testValidation);
10271029
});
10281030
path("/oauth", ()->{
10291031
get("/authorize", ApiRoutes::oauthAuthorize);
@@ -1269,7 +1271,7 @@ public void sessionDestroyed(HttpSessionEvent se){
12691271
MaintenanceScheduler.runPeriodically(()->context.getWallController().removeExpiredGuids(), 1, TimeUnit.HOURS);
12701272
MaintenanceScheduler.runPeriodically(()->context.getCommentsController().removeExpiredGuids(), 1, TimeUnit.HOURS);
12711273
MaintenanceScheduler.runPeriodically(()->context.getMailController().removeExpiredGuids(), 1, TimeUnit.HOURS);
1272-
MaintenanceScheduler.runPeriodically(ApiUtils::removeOldUploadUrlIDs, 10, TimeUnit.MINUTES);
1274+
MaintenanceScheduler.runPeriodically(ApiUtils::doCleanupTasks, 10, TimeUnit.MINUTES);
12731275
context.getUsersController().loadPresenceFromDatabase();
12741276

12751277
Runtime.getRuntime().addShutdownHook(new Thread(()->{

src/main/java/smithereen/Utils.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,8 @@ public static String getLastPathSegment(URI uri){
421421
}
422422

423423
public static SessionInfo sessionInfo(Request req){
424+
if(req.pathInfo().startsWith("/api/"))
425+
return null;
424426
Session sess=req.session(false);
425427
if(sess==null)
426428
return null;
@@ -980,7 +982,7 @@ public static void verifyCaptcha(Request req){
980982
CaptchaInfo info=captchas.remove(sid);
981983
if(info==null)
982984
throw new UserErrorException("err_wrong_captcha");
983-
if(!info.answer().equals(captcha) || System.currentTimeMillis()-info.generatedAt().toEpochMilli()<3000)
985+
if(!info.answer().equals(captcha) || System.currentTimeMillis()-info.generatedAt().toEpochMilli()<1500)
984986
throw new UserErrorException("err_wrong_captcha");
985987
}
986988

@@ -1011,5 +1013,13 @@ public static boolean isValidBirthDate(LocalDate date){
10111013
return date.isBefore(LocalDate.now().plusDays(1)) && date.isAfter(LocalDate.of(1900, 1, 1));
10121014
}
10131015

1016+
public static byte[] tryDecodeBase64Url(String s){
1017+
try{
1018+
return Base64.getUrlDecoder().decode(s);
1019+
}catch(IllegalArgumentException x){
1020+
return null;
1021+
}
1022+
}
1023+
10141024
private record EmailConfirmationCodeInfo(String code, EmailCodeActionType actionType, Instant sentAt){}
10151025
}

src/main/java/smithereen/api/ApiDispatcher.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ public class ApiDispatcher{
8989
registerMethod("utils.getServerTime", UtilsMethods::getServerTime, false);
9090
registerMethod("utils.loadRemoteObject", UtilsMethods::loadRemoteObject, true);
9191
registerMethod("utils.resolveScreenName", UtilsMethods::resolveScreenName, false);
92+
registerMethod("utils.testCaptcha", UtilsMethods::testCaptcha, true);
93+
registerMethod("utils.testValidation", UtilsMethods::testValidation, true);
9294

9395
registerMethod("server.getInfo", ServerMethods::getInfo, false);
9496
registerMethod("server.getRestrictedServers", ServerMethods::getRestrictedServers, false);

src/main/java/smithereen/api/methods/ApiUtils.java

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212
import java.io.ByteArrayOutputStream;
1313
import java.io.IOException;
1414
import java.nio.charset.StandardCharsets;
15+
import java.time.Instant;
1516
import java.util.ArrayList;
17+
import java.util.Arrays;
1618
import java.util.Base64;
1719
import java.util.BitSet;
1820
import java.util.Collection;
21+
import java.util.Collections;
1922
import java.util.EnumSet;
2023
import java.util.HashMap;
2124
import java.util.HashSet;
@@ -28,9 +31,13 @@
2831
import java.util.stream.Collectors;
2932

3033
import smithereen.ApplicationContext;
34+
import smithereen.Config;
35+
import smithereen.Utils;
3136
import smithereen.activitypub.objects.Actor;
3237
import smithereen.activitypub.objects.LocalImage;
3338
import smithereen.api.ApiCallContext;
39+
import smithereen.api.ApiErrorException;
40+
import smithereen.api.model.ApiCaptchaError;
3441
import smithereen.api.model.ApiComment;
3542
import smithereen.api.model.ApiErrorType;
3643
import smithereen.api.model.ApiGroup;
@@ -67,6 +74,8 @@
6774
import smithereen.model.viewmodel.CommentViewModel;
6875
import smithereen.model.viewmodel.PostViewModel;
6976
import smithereen.text.FormattedTextFormat;
77+
import smithereen.util.ByteArrayMapKey;
78+
import smithereen.util.CaptchaGenerator;
7079
import smithereen.util.CryptoUtils;
7180
import smithereen.util.JsonObjectBuilder;
7281
import smithereen.util.UriBuilder;
@@ -78,6 +87,7 @@ public class ApiUtils{
7887
public static final byte[] EXTERNAL_MEDIA_KEY=CryptoUtils.randomBytes(16);
7988
public static final HashMap<Integer, Long> uploadUrlIDs=new HashMap<>();
8089
public static final AtomicInteger lastUploadUrlID=new AtomicInteger();
90+
private static final HashMap<ByteArrayMapKey, CaptchaInfo> captchas=new HashMap<>();
8191

8292
@NotNull
8393
@Unmodifiable
@@ -723,11 +733,14 @@ public static String getUploadURL(ApiCallContext actx, String path, Map<String,
723733
.toString();
724734
}
725735

726-
public static void removeOldUploadUrlIDs(){
736+
public static void doCleanupTasks(){
737+
long now=System.currentTimeMillis();
727738
synchronized(uploadUrlIDs){
728-
long now=System.currentTimeMillis();
729739
uploadUrlIDs.values().removeIf(v->now-v>60_000);
730740
}
741+
synchronized(captchas){
742+
captchas.values().removeIf(ci->now-ci.requestedAt.toEpochMilli()>600_000);
743+
}
731744
}
732745

733746
public static @NotNull FormattedTextFormat getTextFormat(@NotNull ApiCallContext actx){
@@ -772,6 +785,61 @@ public static String getExternalMediaHash(Map<String, String> params){
772785
return Base64.getUrlEncoder().withoutPadding().encodeToString(CryptoUtils.sha256(buf.toByteArray()));
773786
}
774787

775-
public record InputAttachments(@NotNull List<String> ids, @NotNull Map<String, String> altTexts, @Nullable Poll poll){
788+
public static void enforceCaptcha(ApplicationContext ctx, ApiCallContext actx){
789+
if(actx.self==null)
790+
throw new IllegalArgumentException("This method requires an account");
791+
List<String> paramsHashSource=new ArrayList<>();
792+
actx.params.forEach((k, v)->{
793+
if(!"captcha_sid".equals(k) && !"captcha_answer".equals(k))
794+
paramsHashSource.add(k+"="+v);
795+
});
796+
Collections.sort(paramsHashSource);
797+
byte[] paramsHash=CryptoUtils.sha256(String.join("\n", paramsHashSource).getBytes(StandardCharsets.UTF_8));
798+
if(actx.hasParam("captcha_sid") && actx.hasParam("captcha_answer")){
799+
byte[] sid=Utils.tryDecodeBase64Url(actx.requireParamString("captcha_sid"));
800+
if(sid!=null && sid.length==16){
801+
CaptchaInfo info;
802+
ByteArrayMapKey key=new ByteArrayMapKey(sid);
803+
synchronized(captchas){
804+
info=captchas.remove(key);
805+
}
806+
String answer=actx.requireParamString("captcha_answer");
807+
if(info!=null && Arrays.equals(info.accessToken, actx.token.id()) && Arrays.equals(info.paramsHash, paramsHash) && answer.equals(info.answer)){
808+
long now=System.currentTimeMillis();
809+
if(now-info.generatedAt.toEpochMilli()>1500 && now-info.requestedAt.toEpochMilli()<600_000)
810+
return;
811+
}
812+
}
813+
}
814+
815+
byte[] sid=CryptoUtils.randomBytes(16);
816+
synchronized(captchas){
817+
captchas.put(new ByteArrayMapKey(sid), new CaptchaInfo(actx.token.id(), paramsHash, Instant.now(), null, null));
818+
}
819+
ApiCaptchaError error=new ApiCaptchaError(ApiErrorType.CAPTCHA_NEEDED, null, actx.params);
820+
String encodedSid=Base64.getUrlEncoder().withoutPadding().encodeToString(sid);
821+
error.captcha=new ApiCaptchaError.Captcha(Config.localURI("/api/captcha/"+encodedSid+".png").toString(), CaptchaGenerator.IMG_WIDTH, CaptchaGenerator.IMG_HEIGHT, encodedSid, actx.lang.get("captcha_hint"));
822+
throw new ApiErrorException(error);
776823
}
824+
825+
public static CaptchaInfo getCaptchaInfo(byte[] sid){
826+
CaptchaInfo info=captchas.get(new ByteArrayMapKey(sid));
827+
if(info==null || System.currentTimeMillis()-info.requestedAt.toEpochMilli()>600_000)
828+
return null;
829+
return info;
830+
}
831+
832+
public static void updateCaptchaInfo(byte[] sid, String answer){
833+
ByteArrayMapKey key=new ByteArrayMapKey(sid);
834+
synchronized(captchas){
835+
CaptchaInfo info=captchas.get(key);
836+
if(info==null)
837+
return;
838+
captchas.put(key, new CaptchaInfo(info.accessToken, info.paramsHash, info.requestedAt, Instant.now(), answer));
839+
}
840+
}
841+
842+
public record InputAttachments(@NotNull List<String> ids, @NotNull Map<String, String> altTexts, @Nullable Poll poll){}
843+
844+
public record CaptchaInfo(byte[] accessToken, byte[] paramsHash, Instant requestedAt, Instant generatedAt, String answer){}
777845
}

src/main/java/smithereen/api/methods/UtilsMethods.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package smithereen.api.methods;
22

3+
import java.nio.charset.StandardCharsets;
4+
import java.util.Base64;
5+
36
import smithereen.ApplicationContext;
7+
import smithereen.Config;
48
import smithereen.api.ApiCallContext;
9+
import smithereen.api.ApiErrorException;
510
import smithereen.api.model.ApiErrorType;
11+
import smithereen.api.model.ApiValidationError;
612
import smithereen.controllers.ObjectLinkResolver;
713
import smithereen.exceptions.RemoteObjectFetchException;
814
import smithereen.model.Group;
@@ -12,6 +18,7 @@
1218
import smithereen.model.comments.Comment;
1319
import smithereen.model.photos.Photo;
1420
import smithereen.model.photos.PhotoAlbum;
21+
import smithereen.util.CryptoUtils;
1522

1623
public class UtilsMethods{
1724
public static Object getServerTime(ApplicationContext ctx, ApiCallContext actx){
@@ -57,4 +64,16 @@ record ScreenNameResult(String type, long id){}
5764
case APPLICATION -> "application";
5865
}, res.localID());
5966
}
67+
68+
public static Object testCaptcha(ApplicationContext ctx, ApiCallContext actx){
69+
ApiUtils.enforceCaptcha(ctx, actx);
70+
return true;
71+
}
72+
73+
public static Object testValidation(ApplicationContext ctx, ApiCallContext actx){
74+
String successStr=Base64.getUrlEncoder().withoutPadding().encodeToString(CryptoUtils.sha256("Success!".getBytes(StandardCharsets.UTF_8)));
75+
if(!successStr.equals(actx.optParamString("validation_key")))
76+
throw new ApiErrorException(new ApiValidationError(ApiErrorType.VALIDATION_NEEDED, null, actx.params, Config.localURI("/api/testValidation?this_parameter=should%20be%20kept%20intact&this_one=as%20well").toString()));
77+
return true;
78+
}
6079
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package smithereen.api.model;
2+
3+
import java.util.Map;
4+
5+
public class ApiCaptchaError extends ApiError{
6+
public Captcha captcha;
7+
8+
public ApiCaptchaError(ApiErrorType type, String message, Map<String, Object> params){
9+
super(type, message, params);
10+
}
11+
12+
public record Captcha(String url, int width, int height, String sid, String hint){}
13+
}

src/main/java/smithereen/api/model/ApiErrorType.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ public enum ApiErrorType{
1414
INTERNAL_SERVER_ERROR(10, "Internal server error", 500),
1515
EXECUTE_COMPILE_FAILED(12, "Code compilation failed", 422),
1616
EXECUTE_RUNTIME_ERROR(13, "Runtime error occurred", 422),
17-
CAPTCHA_NEEDED(14, "Captcha needed"),
17+
CAPTCHA_NEEDED(14, "Captcha needed", 429),
1818
ACCESS_DENIED(15, "Access denied", 403),
19-
VALIDATION_NEEDED(17, "Validation required"),
19+
VALIDATION_NEEDED(17, "Validation required", 401),
2020
ACCOUNT_SUSPENDED(18, "Account banned", 403),
2121
//STANDALONE_APPS_ONLY(20, "Permission to perform this action is denied for non-standalone applications"),
2222
//CONFIRMATION_NEEDED(24, "Confirmation required"),
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package smithereen.api.model;
2+
3+
import java.util.Map;
4+
5+
public class ApiValidationError extends ApiError{
6+
public final String validationUrl;
7+
8+
public ApiValidationError(ApiErrorType type, String message, Map<String, Object> params, String validationUrl){
9+
super(type, message, params);
10+
this.validationUrl=validationUrl;
11+
}
12+
}

src/main/java/smithereen/routes/ApiRoutes.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import org.slf4j.Logger;
1313
import org.slf4j.LoggerFactory;
1414

15+
import java.io.ByteArrayOutputStream;
16+
import java.io.IOException;
1517
import java.net.URI;
1618
import java.net.URISyntaxException;
1719
import java.net.URLEncoder;
@@ -30,6 +32,8 @@
3032
import java.util.regex.Pattern;
3133
import java.util.stream.Collectors;
3234

35+
import javax.imageio.ImageIO;
36+
3337
import smithereen.ApplicationContext;
3438
import smithereen.Config;
3539
import smithereen.activitypub.objects.LocalImage;
@@ -46,6 +50,7 @@
4650
import smithereen.exceptions.FloodControlViolationException;
4751
import smithereen.exceptions.ObjectNotFoundException;
4852
import smithereen.exceptions.UserActionNotAllowedException;
53+
import smithereen.exceptions.UserErrorException;
4954
import smithereen.http.HttpContentType;
5055
import smithereen.lang.Lang;
5156
import smithereen.model.Account;
@@ -60,6 +65,7 @@
6065
import smithereen.scripting.ScriptValueGsonTypeAdapterFactory;
6166
import smithereen.storage.MediaStorageUtils;
6267
import smithereen.templates.RenderedTemplateResponse;
68+
import smithereen.util.CaptchaGenerator;
6369
import smithereen.util.CryptoUtils;
6470
import smithereen.util.FloodControl;
6571
import smithereen.util.JsonObjectBuilder;
@@ -544,4 +550,50 @@ private static Object uploadPhoto(Request req, Response resp, String method, Med
544550
return new JsonObjectBuilder().add("error", "Field 'photo' not found").build();
545551
}
546552
}
553+
554+
public static Object captcha(Request req, Response resp) throws IOException{
555+
String rawSid=req.params(":sid");
556+
if(!rawSid.endsWith(".png"))
557+
return null;
558+
byte[] sid=tryDecodeBase64Url(rawSid.substring(0, rawSid.length()-4));
559+
if(sid==null)
560+
return null;
561+
ApiUtils.CaptchaInfo info=ApiUtils.getCaptchaInfo(sid);
562+
if(info==null)
563+
return null;
564+
565+
CaptchaGenerator.Captcha captcha=CaptchaGenerator.generate();
566+
ApiUtils.updateCaptchaInfo(sid, captcha.answer());
567+
resp.type("image/png");
568+
ByteArrayOutputStream out=new ByteArrayOutputStream();
569+
ImageIO.write(captcha.image(), "png", out);
570+
return out.toByteArray();
571+
}
572+
573+
public static Object testValidation(Request req, Response resp){
574+
req.attribute("popup", Boolean.TRUE);
575+
try{
576+
if(!"should be kept intact".equals(req.queryParams("this_parameter")) || !"as well".equals(req.queryParams("this_one")))
577+
throw new UserErrorException("You accidentally removed the existing query parameters from the URL while adding your own.");
578+
String rawUrl=req.queryParams("redirect_url");
579+
if(StringUtils.isEmpty(rawUrl))
580+
throw new UserErrorException("The redirect_url parameter is absent or empty.");
581+
URI url;
582+
try{
583+
url=new URI(rawUrl);
584+
}catch(URISyntaxException x){
585+
throw new UserErrorException("redirect_url is not a valid URL.");
586+
}
587+
if(StringUtils.isEmpty(url.getScheme()) || StringUtils.isEmpty(url.getAuthority()))
588+
throw new UserErrorException("redirect_url must have at least scheme and authority parts.");
589+
590+
String successStr=Base64.getUrlEncoder().withoutPadding().encodeToString(CryptoUtils.sha256("Success!".getBytes(StandardCharsets.UTF_8)));
591+
return new RenderedTemplateResponse("validation_test", req)
592+
.with("successURL", new UriBuilder(url).fragment("success=1&key="+successStr).build().toString())
593+
.with("failureURL", new UriBuilder(url).fragment("error=1").build().toString())
594+
.pageTitle("API validation test");
595+
}catch(UserErrorException x){
596+
return new RenderedTemplateResponse("generic_error", req).with("error", x.getMessage()).with("title", lang(req).get("error"));
597+
}
598+
}
547599
}

src/main/java/smithereen/templates/Templates.java

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,8 @@ public static void addGlobalParamsToTemplate(Request req, RenderedTemplateRespon
9595
ApplicationContext ctx=Utils.context(req);
9696
Lang lang=Utils.lang(req);
9797
if(req.session(false)!=null){
98-
SessionInfo info=req.session().attribute("info");
99-
if(info==null){
100-
info=new SessionInfo();
101-
req.session().attribute("info", info);
102-
}
103-
Account account=info.account;
98+
SessionInfo info=Utils.sessionInfo(req);
99+
Account account=info==null ? null : info.account;
104100
if(account!=null){
105101
model.with("currentUser", account.user);
106102
model.with("csrf", info.csrfToken);

0 commit comments

Comments
 (0)