mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-03 20:04:28 +01:00
csrf fixes (#4915)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
parent
5c9e590856
commit
4b43693e29
@ -0,0 +1,37 @@
|
|||||||
|
package stirling.software.common.annotations.api;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined annotation for Invite management controllers.
|
||||||
|
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
|
||||||
|
*/
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/invite")
|
||||||
|
@Tag(
|
||||||
|
name = "Invite",
|
||||||
|
description =
|
||||||
|
"""
|
||||||
|
Invite-link generation and acceptance endpoints for onboarding new users.
|
||||||
|
|
||||||
|
Provides the ability to issue invitation tokens, send optional email invites,
|
||||||
|
validate and accept invite links, and manage pending invitations for teams.
|
||||||
|
|
||||||
|
Typical use cases include:
|
||||||
|
• Admin workflows for issuing time-limited invitations to external users
|
||||||
|
• Self-service invite acceptance and team assignment
|
||||||
|
• License limit enforcement when provisioning new accounts
|
||||||
|
|
||||||
|
Target users: administrators and automation scripts orchestrating user onboarding.
|
||||||
|
""")
|
||||||
|
public @interface InviteApi {}
|
||||||
@ -129,61 +129,53 @@ public class SecurityConfiguration {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
// Read CORS allowed origins from settings
|
List<String> configuredOrigins = null;
|
||||||
if (applicationProperties.getSystem() != null
|
if (applicationProperties.getSystem() != null) {
|
||||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
||||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
|
}
|
||||||
|
|
||||||
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
CorsConfiguration cfg = new CorsConfiguration();
|
||||||
|
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
|
||||||
CorsConfiguration cfg = new CorsConfiguration();
|
cfg.setAllowedOriginPatterns(configuredOrigins);
|
||||||
|
|
||||||
// Use setAllowedOriginPatterns for better wildcard and port support
|
|
||||||
cfg.setAllowedOriginPatterns(allowedOrigins);
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"CORS configured with allowed origin patterns from settings.yml: {}",
|
"CORS configured with allowed origin patterns from settings.yml: {}",
|
||||||
allowedOrigins);
|
configuredOrigins);
|
||||||
|
|
||||||
// Set allowed methods explicitly (including OPTIONS for preflight)
|
|
||||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
|
||||||
|
|
||||||
// Set allowed headers explicitly
|
|
||||||
cfg.setAllowedHeaders(
|
|
||||||
List.of(
|
|
||||||
"Authorization",
|
|
||||||
"Content-Type",
|
|
||||||
"X-Requested-With",
|
|
||||||
"Accept",
|
|
||||||
"Origin",
|
|
||||||
"X-API-KEY",
|
|
||||||
"X-CSRF-TOKEN"));
|
|
||||||
|
|
||||||
// Set exposed headers (headers that the browser can access)
|
|
||||||
cfg.setExposedHeaders(
|
|
||||||
List.of(
|
|
||||||
"WWW-Authenticate",
|
|
||||||
"X-Total-Count",
|
|
||||||
"X-Page-Number",
|
|
||||||
"X-Page-Size",
|
|
||||||
"Content-Disposition",
|
|
||||||
"Content-Type"));
|
|
||||||
|
|
||||||
// Allow credentials (cookies, authorization headers)
|
|
||||||
cfg.setAllowCredentials(true);
|
|
||||||
|
|
||||||
// Set max age for preflight cache
|
|
||||||
cfg.setMaxAge(3600L);
|
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
|
||||||
source.registerCorsConfiguration("/**", cfg);
|
|
||||||
return source;
|
|
||||||
} else {
|
} else {
|
||||||
// No CORS origins configured - return null to disable CORS processing entirely
|
// Default to allowing all origins when nothing is configured
|
||||||
// This avoids empty CORS policy that unexpectedly rejects preflights
|
cfg.setAllowedOriginPatterns(List.of("*"));
|
||||||
log.info(
|
log.info(
|
||||||
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
|
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
|
||||||
|
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||||
|
|
||||||
|
cfg.setAllowedHeaders(
|
||||||
|
List.of(
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-Requested-With",
|
||||||
|
"Accept",
|
||||||
|
"Origin",
|
||||||
|
"X-API-KEY",
|
||||||
|
"X-CSRF-TOKEN",
|
||||||
|
"X-XSRF-TOKEN"));
|
||||||
|
|
||||||
|
cfg.setExposedHeaders(
|
||||||
|
List.of(
|
||||||
|
"WWW-Authenticate",
|
||||||
|
"X-Total-Count",
|
||||||
|
"X-Page-Number",
|
||||||
|
"X-Page-Size",
|
||||||
|
"Content-Disposition",
|
||||||
|
"Content-Type"));
|
||||||
|
|
||||||
|
cfg.setAllowCredentials(true);
|
||||||
|
cfg.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", cfg);
|
||||||
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.common.annotations.api.UserApi;
|
import stirling.software.common.annotations.api.InviteApi;
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.model.enumeration.Role;
|
import stirling.software.common.model.enumeration.Role;
|
||||||
import stirling.software.proprietary.model.Team;
|
import stirling.software.proprietary.model.Team;
|
||||||
@ -26,11 +26,9 @@ import stirling.software.proprietary.security.service.EmailService;
|
|||||||
import stirling.software.proprietary.security.service.TeamService;
|
import stirling.software.proprietary.security.service.TeamService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
|
||||||
@UserApi
|
@InviteApi
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/v1/invite")
|
|
||||||
public class InviteLinkController {
|
public class InviteLinkController {
|
||||||
|
|
||||||
private final InviteTokenRepository inviteTokenRepository;
|
private final InviteTokenRepository inviteTokenRepository;
|
||||||
|
|||||||
@ -107,7 +107,7 @@ class SpringAuthClient {
|
|||||||
for (const cookie of cookies) {
|
for (const cookie of cookies) {
|
||||||
const [name, value] = cookie.trim().split('=');
|
const [name, value] = cookie.trim().split('=');
|
||||||
if (name === 'XSRF-TOKEN') {
|
if (name === 'XSRF-TOKEN') {
|
||||||
return value;
|
return decodeURIComponent(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -278,7 +278,7 @@ class SpringAuthClient {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/api/v1/auth/logout', null, {
|
const response = await apiClient.post('/api/v1/auth/logout', null, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
||||||
},
|
},
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
@ -311,7 +311,7 @@ class SpringAuthClient {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/api/v1/auth/refresh', null, {
|
const response = await apiClient.post('/api/v1/auth/refresh', null, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
||||||
},
|
},
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,17 +9,38 @@ function getJwtTokenFromStorage(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getXsrfToken(): string | null {
|
||||||
|
try {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
const [name, value] = cookie.trim().split('=');
|
||||||
|
if (name === 'XSRF-TOKEN') {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API Client] Failed to read XSRF token from cookies:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||||
// Install request interceptor to add JWT token
|
// Install request interceptor to add JWT token
|
||||||
client.interceptors.request.use(
|
client.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const jwtToken = getJwtTokenFromStorage();
|
const jwtToken = getJwtTokenFromStorage();
|
||||||
|
const xsrfToken = getXsrfToken();
|
||||||
|
|
||||||
if (jwtToken && !config.headers.Authorization) {
|
if (jwtToken && !config.headers.Authorization) {
|
||||||
config.headers.Authorization = `Bearer ${jwtToken}`;
|
config.headers.Authorization = `Bearer ${jwtToken}`;
|
||||||
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
|
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {
|
||||||
|
config.headers['X-XSRF-TOKEN'] = xsrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user