Skip to content

Commit 1cb9140

Browse files
authored
fix: allow anonymous access to /invite/:token accept page (Stirling-Tools#7612)
## Problem In the self-hosted build with login enabled, admin-generated invite links point to the SPA route `/invite/<token>`, but that route is not covered by the anonymous whitelist. Anonymous users get 401 / redirected to `/login` before the React app can mount - even though the APIs the page calls (`/api/v1/invite/validate`, `/api/v1/invite/accept`) are already whitelisted. Since accepting an invite is how a *new* account is created, requiring authentication first makes the feature unusable. ## Fix Add `INVITE_LINK_PATTERN` (`^/invite/[^/]+/?$`) in `RequestUriUtils.java`, matched at the end of `isPublicAuthEndpoint()` - mirroring the existing `SHARE_LINK_PATTERN` handling. The invite data APIs remain protected by their own token validation; only the SPA bootstrap page becomes anonymously reachable. ## Tests Added unit tests in `RequestUriUtilsTest.java` mirroring the share-link tests: - `/invite/<token>` (with/without trailing slash, with context path) ? public - bare `/invite` and `/invite/` ? NOT public (token segment required) - `/invite/<token>/foo` nested paths ? NOT public - `/inviteX` prefix over-match ? NOT public ## Verification Pattern behavior validated against all test cases above. Live-tested on 2.14.3 self-hosted: anonymous `GET /invite/<token>` returned 401 before the fix; the whitelisted accept flow itself (`validate` + `accept` APIs) works anonymously end-to-end.
1 parent f5cf5f1 commit 1cb9140

2 files changed

Lines changed: 89 additions & 1 deletion

File tree

app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
public class RequestUriUtils {
66

77
private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$");
8+
// Invite tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly
9+
private static final Pattern INVITE_LINK_PATTERN =
10+
Pattern.compile(
11+
"^/invite/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$");
812

913
public static boolean isStaticResource(String requestURI) {
1014
return isStaticResource("", requestURI);
@@ -209,7 +213,9 @@ public static boolean isPublicAuthEndpoint(String requestURI, String contextPath
209213
// Workflow participant endpoints - access controlled by share tokens, not login
210214
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
211215
// Share-link SPA bootstrap; data APIs remain protected
212-
|| SHARE_LINK_PATTERN.matcher(trimmedUri).matches();
216+
|| SHARE_LINK_PATTERN.matcher(trimmedUri).matches()
217+
// Invite-accept SPA bootstrap; data APIs remain protected
218+
|| INVITE_LINK_PATTERN.matcher(trimmedUri).matches();
213219
}
214220

215221
private static String stripContextPath(String contextPath, String requestURI) {

app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,86 @@ void testIsPublicAuthEndpoint_shareApiStillProtected() {
236236
RequestUriUtils.isPublicAuthEndpoint(
237237
"/api/v1/storage/share-links/abc123/metadata", ""));
238238
}
239+
240+
// --- invite-accept SPA bootstrap ---
241+
242+
private static final String INVITE_TOKEN = "06a20e7e-2e35-4e26-be7d-2dce14f28f12";
243+
244+
@Test
245+
void testIsPublicAuthEndpoint_inviteLinkToken() {
246+
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN, ""));
247+
}
248+
249+
@Test
250+
void testIsPublicAuthEndpoint_inviteLinkTokenTrailingSlash() {
251+
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/", ""));
252+
}
253+
254+
@Test
255+
void testIsPublicAuthEndpoint_inviteLinkWithContextPath() {
256+
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/invite/" + INVITE_TOKEN, "/app"));
257+
}
258+
259+
@Test
260+
void testIsPublicAuthEndpoint_inviteRootNotPublic() {
261+
// Avoid matching bare "/invite" or "/invite/" - must have a token segment
262+
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite", ""));
263+
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/", ""));
264+
}
265+
266+
@Test
267+
void testIsPublicAuthEndpoint_inviteNestedPathNotPublic() {
268+
// Guard against future additions like /invite/<token>/foo becoming accidentally public
269+
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/foo", ""));
270+
}
271+
272+
@Test
273+
void testIsPublicAuthEndpoint_invitePrefixDoesNotOvermatch() {
274+
// "/inviteX" must not match the invite pattern
275+
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/inviteX", ""));
276+
}
277+
278+
@Test
279+
void testIsPublicAuthEndpoint_inviteNonUuidTokenNotPublic() {
280+
// Only exactly-shaped 36-char lowercase UUID tokens are treated as invite links
281+
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc123", ""));
282+
}
283+
284+
@Test
285+
void testIsPublicAuthEndpoint_inviteUppercaseUuidNotPublic() {
286+
// Tokens are generated lowercase by UUID.randomUUID().toString()
287+
assertFalse(
288+
RequestUriUtils.isPublicAuthEndpoint(
289+
"/invite/06A20E7E-2E35-4E26-BE7D-2DCE14F28F12", ""));
290+
}
291+
292+
@Test
293+
void testIsPublicAuthEndpoint_inviteWrongLengthNotPublic() {
294+
// 35-char and 37-char UUID-like tokens are not valid UUIDs
295+
assertFalse(
296+
RequestUriUtils.isPublicAuthEndpoint(
297+
"/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1", ""));
298+
assertFalse(
299+
RequestUriUtils.isPublicAuthEndpoint(
300+
"/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f122", ""));
301+
}
302+
303+
@Test
304+
void testIsPublicAuthEndpoint_inviteWrongGroupingNotPublic() {
305+
// Groups of 8-4-4-4-4 must not be shifted around (e.g. 4-4-4-4-8)
306+
assertFalse(
307+
RequestUriUtils.isPublicAuthEndpoint(
308+
"/invite/06a2-0e7e-2e35-4e26-be7d2dce14f28f12", ""));
309+
}
310+
311+
@Test
312+
void testIsPublicAuthEndpoint_inviteTokenInvalidCharsNotPublic() {
313+
// Hex-only; anything outside [0-9a-f] or the UUID hyphens is rejected
314+
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc$123", ""));
315+
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc..123", ""));
316+
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc%2F123", ""));
317+
assertFalse(
318+
RequestUriUtils.isPublicAuthEndpoint(
319+
"/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1g", ""));
320+
}
239321
}

0 commit comments

Comments
 (0)