mirror of
https://github.com/booklore-app/booklore.git
synced 2026-04-20 03:26:58 -04:00
refactor(misc): Harden request handling, tighten defaults, and pin CI deps (#2878)
* refactor(misc): Harden request handling, tighten defaults, and pin CI dependencies * feat(auth): normalize usernames for login rate limiting * refactor(epub): fix XML document builder creation * feat(http): enhance image download with redirect handling and DNS rebinding protection * chore: upgrade Node.js version to 24 and add frontend dependency audit step * feat(auth): refactor username normalization for login rate limiting and enhance image download security * fix(metadata): simplify JAXB validation event handling by using direct import * chore(pipeline): remove unnecessary --force flag from npm ci and format audit step * chore(pipeline): streamline frontend dependency audits and add validation step * fix(image): improve error handling for image processing and download, enforce pixel limits * test(image): update tests * fix(icon): update DOMPurify import and replace deprecated theme package * fix(imports): update theme package imports and format code for consistency * fix(cors): update allowed origins configuration to support wildcard and trim whitespace
This commit is contained in:
58
.github/workflows/develop-pipeline.yml
vendored
58
.github/workflows/develop-pipeline.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set Up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
@@ -47,14 +47,14 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Publish Backend Test Results
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
|
||||
if: always()
|
||||
with:
|
||||
files: booklore-api/build/test-results/**/*.xml
|
||||
check_name: Backend Test Results
|
||||
|
||||
- name: Upload Backend Test Reports
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always()
|
||||
with:
|
||||
name: backend-test-reports
|
||||
@@ -82,18 +82,27 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set Up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: booklore-ui/package-lock.json
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
working-directory: ./booklore-ui
|
||||
run: npm ci --force
|
||||
run: npm ci
|
||||
|
||||
- name: Audit Frontend Dependencies
|
||||
working-directory: ./booklore-ui
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Validate Dependency Tree
|
||||
working-directory: ./booklore-ui
|
||||
run: npm ls --depth=0
|
||||
|
||||
|
||||
- name: Execute Frontend Tests
|
||||
id: frontend_tests
|
||||
@@ -104,14 +113,14 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Publish Frontend Test Results
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
|
||||
if: always()
|
||||
with:
|
||||
files: booklore-ui/test-results/vitest-results.xml
|
||||
check_name: Frontend Test Results
|
||||
|
||||
- name: Upload Frontend Test Reports
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always()
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
@@ -136,7 +145,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -161,15 +170,24 @@ jobs:
|
||||
# Native Angular build
|
||||
# ----------------------------------------
|
||||
- name: Set Up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: booklore-ui/package-lock.json
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
working-directory: ./booklore-ui
|
||||
run: npm ci --force
|
||||
run: npm ci
|
||||
|
||||
- name: Audit Frontend Dependencies
|
||||
working-directory: ./booklore-ui
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Validate Dependency Tree
|
||||
working-directory: ./booklore-ui
|
||||
run: npm ls --depth=0
|
||||
|
||||
|
||||
- name: Build Angular App
|
||||
working-directory: ./booklore-ui
|
||||
@@ -179,7 +197,7 @@ jobs:
|
||||
# Native Gradle build
|
||||
# ----------------------------------------
|
||||
- name: Set Up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
@@ -204,24 +222,24 @@ jobs:
|
||||
# Environment setup
|
||||
# ----------------------------------------
|
||||
- name: Set Up QEMU for Multi-Arch Builds
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set Up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# ----------------------------------------
|
||||
# Docker login (pushes & internal PRs only)
|
||||
# ----------------------------------------
|
||||
- name: Authenticate to Docker Hub
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Authenticate to GitHub Container Registry
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -232,7 +250,7 @@ jobs:
|
||||
# ----------------------------------------
|
||||
- name: Build and push Docker image
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.ci
|
||||
|
||||
62
.github/workflows/master-pipeline.yml
vendored
62
.github/workflows/master-pipeline.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
base_ref: ${{ steps.get_base.outputs.base_ref }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Get Base Ref
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set Up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
@@ -59,14 +59,14 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Publish Backend Test Results
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
|
||||
if: always()
|
||||
with:
|
||||
files: booklore-api/build/test-results/**/*.xml
|
||||
check_name: Backend Test Results
|
||||
|
||||
- name: Upload Backend Test Reports
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always()
|
||||
with:
|
||||
name: backend-test-reports
|
||||
@@ -94,18 +94,27 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set Up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: booklore-ui/package-lock.json
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
working-directory: ./booklore-ui
|
||||
run: npm ci --force
|
||||
run: npm ci
|
||||
|
||||
- name: Audit Frontend Dependencies
|
||||
working-directory: ./booklore-ui
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Validate Dependency Tree
|
||||
working-directory: ./booklore-ui
|
||||
run: npm ls --depth=0
|
||||
|
||||
|
||||
- name: Execute Frontend Tests
|
||||
id: frontend_tests
|
||||
@@ -116,14 +125,14 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Publish Frontend Test Results
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
|
||||
if: always()
|
||||
with:
|
||||
files: booklore-ui/test-results/vitest-results.xml
|
||||
check_name: Frontend Test Results
|
||||
|
||||
- name: Upload Frontend Test Reports
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: always()
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
@@ -148,28 +157,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Authenticate to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Authenticate to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set Up QEMU for Multi-Architecture Builds
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Set Up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Retrieve Latest Master Version Tag
|
||||
id: get_version
|
||||
@@ -238,15 +247,24 @@ jobs:
|
||||
# Native Angular build
|
||||
# ----------------------------------------
|
||||
- name: Set Up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: booklore-ui/package-lock.json
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
working-directory: ./booklore-ui
|
||||
run: npm ci --force
|
||||
run: npm ci
|
||||
|
||||
- name: Audit Frontend Dependencies
|
||||
working-directory: ./booklore-ui
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Validate Dependency Tree
|
||||
working-directory: ./booklore-ui
|
||||
run: npm ls --depth=0
|
||||
|
||||
|
||||
- name: Build Angular App
|
||||
working-directory: ./booklore-ui
|
||||
@@ -256,7 +274,7 @@ jobs:
|
||||
# Native Gradle build
|
||||
# ----------------------------------------
|
||||
- name: Set Up JDK 25
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
java-version: '25'
|
||||
distribution: 'temurin'
|
||||
@@ -281,7 +299,7 @@ jobs:
|
||||
# Docker build & push
|
||||
# ----------------------------------------
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.ci
|
||||
@@ -303,7 +321,7 @@ jobs:
|
||||
type=registry,ref=ghcr.io/booklore-app/booklore:buildcache,mode=max
|
||||
|
||||
- name: Update GitHub Release Draft
|
||||
uses: release-drafter/release-drafter@v6
|
||||
uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6
|
||||
with:
|
||||
tag: ${{ env.new_tag }}
|
||||
name: "Release ${{ env.new_tag }}"
|
||||
|
||||
6
.github/workflows/migrations-check.yml
vendored
6
.github/workflows/migrations-check.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
has_migrations: ${{ steps.check_migrations.outputs.has_migrations }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Base Branch
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.base_ref }}
|
||||
fetch-depth: 0
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
migrate
|
||||
|
||||
- name: Checkout Head Branch
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ inputs.head_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build the Angular app
|
||||
FROM node:22-alpine AS angular-build
|
||||
FROM node:24-alpine AS angular-build
|
||||
|
||||
WORKDIR /angular-app
|
||||
|
||||
|
||||
@@ -3,21 +3,26 @@ package org.booklore.config;
|
||||
import org.booklore.config.security.interceptor.WebSocketAuthInterceptor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.messaging.simp.config.ChannelRegistration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
private final WebSocketAuthInterceptor webSocketAuthInterceptor;
|
||||
private final Environment env;
|
||||
|
||||
public WebSocketConfig(WebSocketAuthInterceptor webSocketAuthInterceptor) {
|
||||
public WebSocketConfig(WebSocketAuthInterceptor webSocketAuthInterceptor, Environment env) {
|
||||
this.webSocketAuthInterceptor = webSocketAuthInterceptor;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -29,8 +34,22 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws").setAllowedOrigins("*");
|
||||
log.info("WebSocket endpoint registered at /ws");
|
||||
String allowedOrigins = env.getProperty("app.cors.allowed-origins", "*").trim();
|
||||
var endpoint = registry.addEndpoint("/ws");
|
||||
if ("*".equals(allowedOrigins)) {
|
||||
endpoint.setAllowedOriginPatterns("*");
|
||||
log.warn("WebSocket endpoint is configured to allow all origins (*). " +
|
||||
"This is the default for backward compatibility, but it's recommended to set 'app.cors.allowed-origins' to an explicit list.");
|
||||
} else if (allowedOrigins.isEmpty()) {
|
||||
// No explicit origins configured: enforce same-origin check (Spring WebSocket default)
|
||||
log.info("WebSocket endpoint registered at /ws (same-origin only)");
|
||||
} else {
|
||||
String[] origins = Arrays.stream(allowedOrigins.split("\\s*,\\s*"))
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toArray(String[]::new);
|
||||
endpoint.setAllowedOriginPatterns(origins);
|
||||
log.info("WebSocket endpoint registered at /ws with allowed origins: {}", Arrays.toString(origins));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -5,9 +5,11 @@ import org.booklore.config.security.service.OpdsUserDetailsService;
|
||||
import jakarta.servlet.DispatcherType;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.booklore.util.FileService;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
@@ -19,21 +21,41 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import java.net.InetAddress;
|
||||
import java.net.Socket;
|
||||
import java.util.Collections;
|
||||
|
||||
@Slf4j
|
||||
@AllArgsConstructor
|
||||
@EnableMethodSecurity
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
private static final Pattern ALLOWED = Pattern.compile("\\s*,\\s*");
|
||||
private final OpdsUserDetailsService opdsUserDetailsService;
|
||||
private final DualJwtAuthenticationFilter dualJwtAuthenticationFilter;
|
||||
private final Environment env;
|
||||
|
||||
private static final String[] COMMON_PUBLIC_ENDPOINTS = {
|
||||
"/ws/**", // WebSocket connections (auth handled in WebSocketAuthInterceptor)
|
||||
@@ -197,6 +219,10 @@ public class SecurityConfig {
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.headers(headers -> headers
|
||||
.referrerPolicy(referrer -> referrer.policy(
|
||||
ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()
|
||||
.requestMatchers(publicEndpoints.toArray(new String[0])).permitAll()
|
||||
@@ -212,7 +238,11 @@ public class SecurityConfig {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
|
||||
.headers(headers -> headers
|
||||
.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
|
||||
.referrerPolicy(referrer -> referrer.policy(
|
||||
ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
|
||||
return http.build();
|
||||
}
|
||||
@@ -224,10 +254,92 @@ public class SecurityConfig {
|
||||
return auth.build();
|
||||
}
|
||||
|
||||
@Bean("noRedirectRestTemplate")
|
||||
public RestTemplate noRedirectRestTemplate() {
|
||||
return new RestTemplate(
|
||||
new SimpleClientHttpRequestFactory() {
|
||||
@Override
|
||||
protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException {
|
||||
super.prepareConnection(connection, httpMethod);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
if (connection instanceof HttpsURLConnection httpsConnection) {
|
||||
String targetHost = FileService.getTargetHost();
|
||||
if (targetHost != null) {
|
||||
// Set original host for SNI (even if connecting to IP)
|
||||
SSLSocketFactory defaultFactory = httpsConnection.getSSLSocketFactory();
|
||||
httpsConnection.setSSLSocketFactory(new SniSSLSocketFactory(defaultFactory, targetHost));
|
||||
|
||||
httpsConnection.setHostnameVerifier((hostname, session) -> {
|
||||
String expectedHost = FileService.getTargetHost();
|
||||
if (expectedHost != null) {
|
||||
// Verify certificate against the original expected hostname, even if connecting via IP
|
||||
return HttpsURLConnection.getDefaultHostnameVerifier().verify(expectedHost, session);
|
||||
}
|
||||
// Fallback: use default verifier for the hostname we connected to
|
||||
return HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static class SniSSLSocketFactory extends SSLSocketFactory {
|
||||
private final SSLSocketFactory delegate;
|
||||
private final String targetHost;
|
||||
|
||||
public SniSSLSocketFactory(SSLSocketFactory delegate, String targetHost) {
|
||||
this.delegate = delegate;
|
||||
this.targetHost = targetHost;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getDefaultCipherSuites() { return delegate.getDefaultCipherSuites(); }
|
||||
@Override
|
||||
public String[] getSupportedCipherSuites() { return delegate.getSupportedCipherSuites(); }
|
||||
|
||||
@Override
|
||||
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
|
||||
// Pass targetHost instead of host (which is the IP) so the internal SSLSession gets the correct peer host
|
||||
Socket socket = delegate.createSocket(s, targetHost, port, autoClose);
|
||||
if (socket instanceof SSLSocket sslSocket) {
|
||||
SNIHostName serverName = new SNIHostName(targetHost);
|
||||
SSLParameters params = sslSocket.getSSLParameters();
|
||||
params.setServerNames(Collections.singletonList(serverName));
|
||||
// Explicitly set EndpointIdentificationAlgorithm so Java verifies the certificate against targetHost
|
||||
params.setEndpointIdentificationAlgorithm("HTTPS");
|
||||
sslSocket.setSSLParameters(params);
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override public Socket createSocket(String host, int port) throws IOException { return delegate.createSocket(host, port); }
|
||||
@Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { return delegate.createSocket(host, port, localHost, localPort); }
|
||||
@Override public Socket createSocket(InetAddress host, int port) throws IOException { return delegate.createSocket(host, port); }
|
||||
@Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { return delegate.createSocket(address, port, localAddress, localPort); }
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
|
||||
String allowedOriginsStr = env.getProperty("app.cors.allowed-origins", "*").trim();
|
||||
if ("*".equals(allowedOriginsStr) || allowedOriginsStr.isEmpty()) {
|
||||
log.warn(
|
||||
"CORS is configured to allow all origins (*) because 'app.cors.allowed-origins' is '{}'. " +
|
||||
"This maintains backward compatibility, but it's recommended to set it to an explicit origin list.",
|
||||
allowedOriginsStr.isEmpty() ? "empty" : "*"
|
||||
);
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
} else {
|
||||
List<String> origins = Arrays.stream(ALLOWED.split(allowedOriginsStr))
|
||||
.filter(s -> !s.isEmpty())
|
||||
.map(String::trim)
|
||||
.toList();
|
||||
configuration.setAllowedOriginPatterns(origins);
|
||||
}
|
||||
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||
configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type", "Range", "If-None-Match"));
|
||||
configuration.setExposedHeaders(List.of("Content-Disposition", "Accept-Ranges", "Content-Range", "Content-Length", "ETag", "Date"));
|
||||
@@ -238,4 +350,4 @@ public class SecurityConfig {
|
||||
|
||||
return source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public class AuthRateLimitService {
|
||||
public AuthRateLimitService(AuditService auditService) {
|
||||
this.auditService = auditService;
|
||||
this.attemptCache = Caffeine.newBuilder()
|
||||
.maximumSize(10000)
|
||||
.expireAfterWrite(Duration.ofMinutes(15))
|
||||
.build();
|
||||
}
|
||||
@@ -30,15 +31,30 @@ public class AuthRateLimitService {
|
||||
// --- Login rate limiting ---
|
||||
|
||||
public void checkLoginRateLimit(String ip) {
|
||||
checkRateLimit("login:" + ip, AuditAction.LOGIN_RATE_LIMITED, "Login rate limited for IP: " + ip);
|
||||
checkRateLimit("login:ip:" + ip, AuditAction.LOGIN_RATE_LIMITED, "Login rate limited for IP: " + ip);
|
||||
}
|
||||
|
||||
public void checkLoginRateLimitByUsername(String username) {
|
||||
String normalizedUsername = normalizeUsername(username);
|
||||
checkRateLimit("login:user:" + normalizedUsername, AuditAction.LOGIN_RATE_LIMITED, "Login rate limited for username: " + normalizedUsername);
|
||||
}
|
||||
|
||||
public void recordFailedLoginAttempt(String ip) {
|
||||
recordFailedAttempt("login:" + ip);
|
||||
recordFailedAttempt("login:ip:" + ip);
|
||||
}
|
||||
|
||||
public void recordFailedLoginAttemptByUsername(String username) {
|
||||
String normalizedUsername = normalizeUsername(username);
|
||||
recordFailedAttempt("login:user:" + normalizedUsername);
|
||||
}
|
||||
|
||||
public void resetLoginAttempts(String ip) {
|
||||
resetAttempts("login:" + ip);
|
||||
resetAttempts("login:ip:" + ip);
|
||||
}
|
||||
|
||||
public void resetLoginAttemptsByUsername(String username) {
|
||||
String normalizedUsername = normalizeUsername(username);
|
||||
resetAttempts("login:user:" + normalizedUsername);
|
||||
}
|
||||
|
||||
// --- Refresh token rate limiting ---
|
||||
@@ -72,4 +88,8 @@ public class AuthRateLimitService {
|
||||
private void resetAttempts(String key) {
|
||||
attemptCache.invalidate(key);
|
||||
}
|
||||
|
||||
private String normalizeUsername(String username) {
|
||||
return username != null ? username.trim().toLowerCase() : "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.booklore.config.security.service;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.config.AppProperties;
|
||||
import org.booklore.config.security.JwtUtils;
|
||||
@@ -31,10 +32,12 @@ import org.booklore.service.audit.AuditService;
|
||||
import org.booklore.util.RequestUtils;
|
||||
|
||||
@Slf4j
|
||||
@AllArgsConstructor
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class AuthenticationService {
|
||||
|
||||
private String dummyPasswordHash;
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final UserRepository userRepository;
|
||||
private final RefreshTokenRepository refreshTokenRepository;
|
||||
@@ -45,6 +48,11 @@ public class AuthenticationService {
|
||||
private final AuditService auditService;
|
||||
private final AuthRateLimitService authRateLimitService;
|
||||
|
||||
@PostConstruct
|
||||
void initDummyHash() {
|
||||
this.dummyPasswordHash = passwordEncoder.encode("_dummy_placeholder_for_timing_equalization_");
|
||||
}
|
||||
|
||||
public BookLoreUser getAuthenticatedUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null) {
|
||||
@@ -93,21 +101,31 @@ public class AuthenticationService {
|
||||
|
||||
public ResponseEntity<Map<String, String>> loginUser(UserLoginRequest loginRequest) {
|
||||
String ip = RequestUtils.getCurrentRequest().getRemoteAddr();
|
||||
String username = loginRequest.getUsername();
|
||||
authRateLimitService.checkLoginRateLimit(ip);
|
||||
authRateLimitService.checkLoginRateLimitByUsername(username);
|
||||
|
||||
BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> {
|
||||
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for unknown user: " + loginRequest.getUsername());
|
||||
BookLoreUserEntity user = userRepository.findByUsername(username).orElse(null);
|
||||
|
||||
if (user == null) {
|
||||
// Constant-time dummy BCrypt check prevents timing-based user enumeration:
|
||||
// without this, unknown-user responses are ~3x faster than wrong-password responses.
|
||||
passwordEncoder.matches(loginRequest.getPassword(), dummyPasswordHash);
|
||||
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for unknown user: " + username);
|
||||
authRateLimitService.recordFailedLoginAttempt(ip);
|
||||
return ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername());
|
||||
});
|
||||
authRateLimitService.recordFailedLoginAttemptByUsername(username);
|
||||
throw ApiError.INVALID_CREDENTIALS.createException();
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPasswordHash())) {
|
||||
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for user: " + loginRequest.getUsername());
|
||||
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for user: " + username);
|
||||
authRateLimitService.recordFailedLoginAttempt(ip);
|
||||
authRateLimitService.recordFailedLoginAttemptByUsername(username);
|
||||
throw ApiError.INVALID_CREDENTIALS.createException();
|
||||
}
|
||||
|
||||
authRateLimitService.resetLoginAttempts(ip);
|
||||
authRateLimitService.resetLoginAttemptsByUsername(username);
|
||||
return loginUser(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ public class OpdsUserDetailsService implements UserDetailsService {
|
||||
@Override
|
||||
public OpdsUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
OpdsUserV2Entity userV2 = opdsUserV2Repository.findByUsername(username)
|
||||
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username));
|
||||
.orElseThrow(() -> new UsernameNotFoundException("Invalid credentials"));
|
||||
OpdsUserV2 mappedCredential = opdsUserV2Mapper.toDto(userV2);
|
||||
return new OpdsUserDetails(mappedCredential);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.booklore.controller;
|
||||
|
||||
import org.booklore.service.AuthorMetadataService;
|
||||
import org.booklore.config.security.annotation.CheckBookAccess;
|
||||
import org.booklore.service.book.BookService;
|
||||
import org.booklore.service.bookdrop.BookDropService;
|
||||
import org.booklore.service.reader.CbxReaderService;
|
||||
@@ -32,6 +33,7 @@ public class BookMediaController {
|
||||
@Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a specific book.")
|
||||
@ApiResponse(responseCode = "200", description = "Book thumbnail returned successfully")
|
||||
@GetMapping("/book/{bookId}/thumbnail")
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
public ResponseEntity<Resource> getBookThumbnail(@Parameter(description = "ID of the book") @PathVariable long bookId) {
|
||||
return ResponseEntity.ok(bookService.getBookThumbnail(bookId));
|
||||
}
|
||||
@@ -39,6 +41,7 @@ public class BookMediaController {
|
||||
@Operation(summary = "Get book cover", description = "Retrieve the cover image for a specific book.")
|
||||
@ApiResponse(responseCode = "200", description = "Book cover returned successfully")
|
||||
@GetMapping("/book/{bookId}/cover")
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
public ResponseEntity<Resource> getBookCover(@Parameter(description = "ID of the book") @PathVariable long bookId) {
|
||||
return ResponseEntity.ok(bookService.getBookCover(bookId));
|
||||
}
|
||||
@@ -46,6 +49,7 @@ public class BookMediaController {
|
||||
@Operation(summary = "Get audiobook thumbnail", description = "Retrieve the audiobook thumbnail image for a specific book.")
|
||||
@ApiResponse(responseCode = "200", description = "Audiobook thumbnail returned successfully")
|
||||
@GetMapping("/book/{bookId}/audiobook-thumbnail")
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
public ResponseEntity<Resource> getAudiobookThumbnail(@Parameter(description = "ID of the book") @PathVariable long bookId) {
|
||||
return ResponseEntity.ok(bookService.getAudiobookThumbnail(bookId));
|
||||
}
|
||||
@@ -53,6 +57,7 @@ public class BookMediaController {
|
||||
@Operation(summary = "Get audiobook cover", description = "Retrieve the audiobook cover image for a specific book.")
|
||||
@ApiResponse(responseCode = "200", description = "Audiobook cover returned successfully")
|
||||
@GetMapping("/book/{bookId}/audiobook-cover")
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
public ResponseEntity<Resource> getAudiobookCover(@Parameter(description = "ID of the book") @PathVariable long bookId) {
|
||||
return ResponseEntity.ok(bookService.getAudiobookCover(bookId));
|
||||
}
|
||||
@@ -60,6 +65,7 @@ public class BookMediaController {
|
||||
@Operation(summary = "Get CBX page as image", description = "Retrieve a specific page from a CBX book as an image.")
|
||||
@ApiResponse(responseCode = "200", description = "CBX page image returned successfully")
|
||||
@GetMapping("/book/{bookId}/cbx/pages/{pageNumber}")
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
public void getCbxPage(
|
||||
@Parameter(description = "ID of the book") @PathVariable Long bookId,
|
||||
@Parameter(description = "Page number to retrieve") @PathVariable int pageNumber,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.booklore.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import org.booklore.exception.ErrorResponse;
|
||||
import org.booklore.model.dto.request.InitialUserRequest;
|
||||
import org.booklore.model.dto.response.SuccessResponse;
|
||||
@@ -35,7 +36,7 @@ public class SetupController {
|
||||
@ApiResponse(responseCode = "200", description = "Admin user created successfully")
|
||||
@PostMapping
|
||||
public ResponseEntity<?> setupFirstUser(
|
||||
@Parameter(description = "Initial user request") @RequestBody InitialUserRequest request) {
|
||||
@Parameter(description = "Initial user request") @RequestBody @Valid InitialUserRequest request) {
|
||||
if (userProvisioningService.isInitialUserAlreadyProvisioned()) {
|
||||
return ResponseEntity.status(403).body(new ErrorResponse(403, "Setup is disabled after the first user is created."));
|
||||
}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
public class UserCreateRequest {
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 8, max = 72, message = "Password must be at least 8 characters long")
|
||||
private String password;
|
||||
|
||||
@NotBlank
|
||||
private String name;
|
||||
|
||||
@NotBlank
|
||||
@Email
|
||||
private String email;
|
||||
|
||||
private boolean permissionUpload;
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.booklore.model.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -17,5 +18,6 @@ public class InitialUserRequest {
|
||||
private String name;
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 8, max = 72, message = "Password must be between 8 and 72 characters long")
|
||||
private String password;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.booklore.service.metadata.extractor;
|
||||
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.util.SecureXmlUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -9,7 +10,6 @@ import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.File;
|
||||
@@ -37,9 +37,7 @@ public class Fb2MetadataExtractor implements FileMetadataExtractor {
|
||||
@Override
|
||||
public byte[] extractCover(File file) {
|
||||
try (InputStream inputStream = getInputStream(file)) {
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
DocumentBuilderFactory dbf = SecureXmlUtils.createSecureDocumentBuilderFactory(true);
|
||||
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||
Document doc = builder.parse(inputStream);
|
||||
|
||||
@@ -93,9 +91,7 @@ public class Fb2MetadataExtractor implements FileMetadataExtractor {
|
||||
@Override
|
||||
public BookMetadata extractMetadata(File file) {
|
||||
try (InputStream inputStream = getInputStream(file)) {
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
DocumentBuilderFactory dbf = SecureXmlUtils.createSecureDocumentBuilderFactory(true);
|
||||
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||
Document doc = builder.parse(inputStream);
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ import com.github.junrar.rarfile.FileHeader;
|
||||
import jakarta.xml.bind.JAXBContext;
|
||||
import jakarta.xml.bind.Marshaller;
|
||||
import jakarta.xml.bind.Unmarshaller;
|
||||
import jakarta.xml.bind.ValidationEvent;
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.SAXParserFactory;
|
||||
import javax.xml.transform.sax.SAXSource;
|
||||
import org.xml.sax.InputSource;
|
||||
import org.xml.sax.XMLReader;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
|
||||
@@ -47,10 +53,6 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
private static final JAXBContext JAXB_CONTEXT;
|
||||
|
||||
static {
|
||||
// XXE protection: Disable external DTD and Schema access for security
|
||||
System.setProperty("javax.xml.accessExternalDTD", "");
|
||||
System.setProperty("javax.xml.accessExternalSchema", "");
|
||||
|
||||
try {
|
||||
JAXB_CONTEXT = JAXBContext.newInstance(ComicInfo.class);
|
||||
} catch (jakarta.xml.bind.JAXBException e) {
|
||||
@@ -447,20 +449,32 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
|
||||
private ComicInfo parseComicInfo(InputStream xmlStream) throws Exception {
|
||||
// Use a SAXSource with an explicitly secured XMLReader to prevent XXE injection.
|
||||
// This is more robust than System.setProperty() because it is per-instance and
|
||||
// cannot be inadvertently disabled by other code running in the same JVM.
|
||||
SAXParserFactory spf = SAXParserFactory.newInstance();
|
||||
spf.setNamespaceAware(true);
|
||||
spf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
spf.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
XMLReader xmlReader = spf.newSAXParser().getXMLReader();
|
||||
SAXSource saxSource = new SAXSource(xmlReader, new InputSource(xmlStream));
|
||||
|
||||
Unmarshaller unmarshaller = JAXB_CONTEXT.createUnmarshaller();
|
||||
unmarshaller.setEventHandler(event -> {
|
||||
if (event.getSeverity() == jakarta.xml.bind.ValidationEvent.WARNING ||
|
||||
event.getSeverity() == jakarta.xml.bind.ValidationEvent.ERROR) {
|
||||
log.warn("JAXB Parsing Issue: {} [Line: {}, Col: {}]",
|
||||
event.getMessage(),
|
||||
event.getLocator().getLineNumber(),
|
||||
if (event.getSeverity() == ValidationEvent.WARNING ||
|
||||
event.getSeverity() == ValidationEvent.ERROR) {
|
||||
log.warn("JAXB Parsing Issue: {} [Line: {}, Col: {}]",
|
||||
event.getMessage(),
|
||||
event.getLocator().getLineNumber(),
|
||||
event.getLocator().getColumnNumber());
|
||||
}
|
||||
return true; // Continue processing
|
||||
});
|
||||
ComicInfo result = (ComicInfo) unmarshaller.unmarshal(xmlStream);
|
||||
log.debug("CbxMetadataWriter: Parsed ComicInfo - Title: {}, Summary length: {}",
|
||||
result.getTitle(),
|
||||
ComicInfo result = (ComicInfo) unmarshaller.unmarshal(saxSource);
|
||||
log.debug("CbxMetadataWriter: Parsed ComicInfo - Title: {}, Summary length: {}",
|
||||
result.getTitle(),
|
||||
result.getSummary() != null ? result.getSummary().length() : 0);
|
||||
return result;
|
||||
}
|
||||
@@ -703,8 +717,12 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
if (entryName == null || entryName.isBlank()) return false;
|
||||
String normalized = entryName.replace('\\', '/');
|
||||
if (normalized.startsWith("/")) return false;
|
||||
if (normalized.contains("../")) return false;
|
||||
if (normalized.contains("\0")) return false;
|
||||
// Check each path component for ".." traversal. Splitting with -1 limit catches
|
||||
// trailing separators and avoids missing ".." at the end of a path (e.g. "foo/..").
|
||||
for (String component : normalized.split("/", -1)) {
|
||||
if ("..".equals(component)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -472,7 +473,7 @@ public class OpdsFeedService {
|
||||
|
||||
String queryString = request.getQueryString();
|
||||
if (queryString != null) {
|
||||
java.util.Arrays.stream(queryString.split("&"))
|
||||
Arrays.stream(queryString.split("&"))
|
||||
.filter(param -> !param.startsWith("page=") && !param.startsWith("size="))
|
||||
.forEach(param -> result.append(param).append("&"));
|
||||
}
|
||||
|
||||
@@ -437,6 +437,13 @@ public class CbxReaderService {
|
||||
if (normalized.startsWith("__MACOSX/") || normalized.contains("/__MACOSX/")) {
|
||||
return false;
|
||||
}
|
||||
// Prevent path traversal: reject any entry whose path contains ".." as a component.
|
||||
// Checks split-by-/ to catch "foo/..", ".." alone, and not just "../" (with trailing slash).
|
||||
for (String component : normalized.split("/", -1)) {
|
||||
if ("..".equals(component)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
String baseName = baseName(normalized).toLowerCase();
|
||||
if (baseName.startsWith("._") || baseName.startsWith(".")) {
|
||||
return false;
|
||||
|
||||
@@ -20,11 +20,13 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile;
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.booklore.util.SecureXmlUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
@@ -63,12 +65,7 @@ public class EpubReaderService {
|
||||
|
||||
private static final ThreadLocal<DocumentBuilder> DOCUMENT_BUILDER = ThreadLocal.withInitial(() -> {
|
||||
try {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
return factory.newDocumentBuilder();
|
||||
return SecureXmlUtils.createSecureDocumentBuilder(true);
|
||||
} catch (ParserConfigurationException e) {
|
||||
throw new RuntimeException("Failed to create DocumentBuilder", e);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.booklore.service.upload;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.booklore.config.AppProperties;
|
||||
import org.booklore.exception.ApiError;
|
||||
import org.booklore.mapper.AdditionalFileMapper;
|
||||
@@ -21,7 +22,7 @@ import org.booklore.service.file.FileMovingHelper;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.booklore.service.metadata.extractor.MetadataExtractorFactory;
|
||||
import org.booklore.util.PathPatternResolver;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.util.StringUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -262,7 +263,13 @@ public class FileUploadService {
|
||||
if (originalFileName == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
return originalFileName;
|
||||
// Prevent Path Traversal by extracting only the base file name
|
||||
String cleanPath = StringUtils.cleanPath(originalFileName);
|
||||
String baseFileName = StringUtils.getFilename(cleanPath);
|
||||
if (baseFileName == null || baseFileName.isEmpty() || baseFileName.equals("..")) {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
return baseFileName;
|
||||
}
|
||||
|
||||
private BookFileExtension getFileExtension(String fileName) {
|
||||
|
||||
@@ -11,22 +11,30 @@ import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Iterator;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -35,9 +43,32 @@ import java.util.stream.Stream;
|
||||
@Service
|
||||
public class FileService {
|
||||
|
||||
private static final ThreadLocal<String> TARGET_HOST_THREAD_LOCAL = new ThreadLocal<>();
|
||||
|
||||
public static String getTargetHost() {
|
||||
return TARGET_HOST_THREAD_LOCAL.get();
|
||||
}
|
||||
|
||||
public static void setTargetHost(String host) {
|
||||
TARGET_HOST_THREAD_LOCAL.set(host);
|
||||
}
|
||||
|
||||
public static void clearTargetHost() {
|
||||
TARGET_HOST_THREAD_LOCAL.remove();
|
||||
}
|
||||
|
||||
static {
|
||||
// Enable restricted headers to allow 'Host' header override for DNS rebinding protection
|
||||
System.setProperty("sun.net.http.allowRestrictedHeaders", "true");
|
||||
}
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final RestTemplate restTemplate;
|
||||
private final AppSettingService appSettingService;
|
||||
private final RestTemplate noRedirectRestTemplate;
|
||||
|
||||
private static final int MAX_REDIRECTS = 5;
|
||||
|
||||
|
||||
private static final double TARGET_COVER_ASPECT_RATIO = 1.5;
|
||||
private static final int SMART_CROP_COLOR_TOLERANCE = 30;
|
||||
@@ -58,6 +89,8 @@ public class FileService {
|
||||
private static final String JPEG_MIME_TYPE = "image/jpeg";
|
||||
private static final String PNG_MIME_TYPE = "image/png";
|
||||
private static final long MAX_FILE_SIZE_BYTES = 5L * 1024 * 1024;
|
||||
// 20 MP covers legitimate book covers and author photos with a comfortable safety margin.
|
||||
private static final long MAX_IMAGE_PIXELS = 20_000_000L;
|
||||
private static final int THUMBNAIL_WIDTH = 250;
|
||||
private static final int THUMBNAIL_HEIGHT = 350;
|
||||
private static final int SQUARE_THUMBNAIL_SIZE = 250;
|
||||
@@ -137,6 +170,7 @@ public class FileService {
|
||||
return Paths.get(appProperties.getPathConfig(), "tools", "kepubify").toString();
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// VALIDATION
|
||||
// ========================================
|
||||
@@ -170,18 +204,34 @@ public class FileService {
|
||||
if (imageData == null || imageData.length == 0) {
|
||||
throw new IOException("Image data is null or empty");
|
||||
}
|
||||
try (InputStream is = new ByteArrayInputStream(imageData)) {
|
||||
BufferedImage image = ImageIO.read(is);
|
||||
if (image != null) {
|
||||
return image;
|
||||
|
||||
try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(imageData))) {
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReaders(iis);
|
||||
if (readers.hasNext()) {
|
||||
ImageReader reader = readers.next();
|
||||
try {
|
||||
reader.setInput(iis);
|
||||
int width = reader.getWidth(0);
|
||||
int height = reader.getHeight(0);
|
||||
|
||||
long pixelCount = (long) width * height;
|
||||
if (pixelCount > MAX_IMAGE_PIXELS) {
|
||||
throw new IOException(String.format("Rejected image: dimensions %dx%d (%d pixels) exceed limit %d — possible decompression bomb",
|
||||
width, height, pixelCount, MAX_IMAGE_PIXELS));
|
||||
}
|
||||
|
||||
return reader.read(0);
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("ImageIO/TwelveMonkeys decode failed (possibly unsupported format like AVIF/HEIC): {}", e.getMessage());
|
||||
return null;
|
||||
throw new IOException("ImageIO decode failed (possibly unsupported format): " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
log.warn("Unable to decode image - likely unsupported format (AVIF, HEIC, or SVG)");
|
||||
return null;
|
||||
throw new IOException("Unable to decode image, likely unsupported format");
|
||||
}
|
||||
|
||||
public static BufferedImage resizeImage(BufferedImage originalImage, int width, int height) {
|
||||
@@ -217,31 +267,138 @@ public class FileService {
|
||||
|
||||
public BufferedImage downloadImageFromUrl(String imageUrl) throws IOException {
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set(HttpHeaders.USER_AGENT, "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/booklore-app/booklore)");
|
||||
headers.set(HttpHeaders.ACCEPT, "image/*");
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<byte[]> response = restTemplate.exchange(
|
||||
imageUrl,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
byte[].class
|
||||
);
|
||||
|
||||
// Validate and convert
|
||||
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
||||
return readImage(response.getBody());
|
||||
} else {
|
||||
throw new IOException("Failed to download image. HTTP Status: " + response.getStatusCode());
|
||||
}
|
||||
return downloadImageFromUrlInternal(imageUrl);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to download image from URL: {} - {}", imageUrl, e.getMessage());
|
||||
throw new IOException("Failed to download image from URL: " + imageUrl + " - " + e.getMessage(), e);
|
||||
log.warn("Failed to download image from {}: {}", imageUrl, e.getMessage());
|
||||
if (e instanceof IOException ioException) {
|
||||
throw ioException;
|
||||
}
|
||||
throw new IOException("Failed to download image from " + imageUrl + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage downloadImageFromUrlInternal(String imageUrl) throws IOException {
|
||||
String currentUrl = imageUrl;
|
||||
int redirectCount = 0;
|
||||
|
||||
try {
|
||||
while (redirectCount <= MAX_REDIRECTS) {
|
||||
URI uri = URI.create(currentUrl);
|
||||
// Protocol validation
|
||||
if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) {
|
||||
throw new IOException("Only HTTP and HTTPS protocols are allowed");
|
||||
}
|
||||
|
||||
String host = uri.getHost();
|
||||
if (host == null) {
|
||||
throw new IOException("Invalid URL: no host found in " + currentUrl);
|
||||
}
|
||||
|
||||
// Resolve host to IP to prevent DNS rebinding (TOCTOU)
|
||||
InetAddress[] inetAddresses = InetAddress.getAllByName(host);
|
||||
if (inetAddresses.length == 0) {
|
||||
throw new IOException("Could not resolve host: " + host);
|
||||
}
|
||||
for (InetAddress inetAddress : inetAddresses) {
|
||||
if (isInternalAddress(inetAddress)) {
|
||||
throw new SecurityException("URL points to a local or private internal network address: " + host + " (" + inetAddress.getHostAddress() + ")");
|
||||
}
|
||||
}
|
||||
String ipAddress = inetAddresses[0].getHostAddress();
|
||||
|
||||
// Set target host for SNI / Hostname verification in RestTemplate/SSLSocketFactory
|
||||
setTargetHost(host);
|
||||
|
||||
// Build request URL with IP address to ensure we connect to the validated address.
|
||||
// We keep the original URI's path and query.
|
||||
String portSuffix = (uri.getPort() != -1) ? ":" + uri.getPort() : "";
|
||||
String path = uri.getRawPath();
|
||||
if (path == null || path.isEmpty()) path = "/";
|
||||
String query = uri.getRawQuery();
|
||||
|
||||
// Handle IPv6 address formatting in URL
|
||||
String hostInUrl = ipAddress;
|
||||
if (ipAddress.contains(":")) {
|
||||
hostInUrl = "[" + ipAddress + "]";
|
||||
}
|
||||
String requestUrl = uri.getScheme() + "://" + hostInUrl + portSuffix + path + (query != null ? "?" + query : "");
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
// Set original 'Host' header for server-side virtual hosting
|
||||
headers.set(HttpHeaders.HOST, host);
|
||||
headers.set(HttpHeaders.USER_AGENT, "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/booklore-app/booklore)");
|
||||
headers.set(HttpHeaders.ACCEPT, "image/*");
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
log.debug("Downloading image via IP-based URL: {} (Original host: {})", requestUrl, host);
|
||||
|
||||
ResponseEntity<byte[]> response = noRedirectRestTemplate.exchange(
|
||||
requestUrl,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
byte[].class
|
||||
);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
||||
return readImage(response.getBody());
|
||||
} else if (response.getStatusCode().is3xxRedirection()) {
|
||||
String location = response.getHeaders().getFirst(HttpHeaders.LOCATION);
|
||||
if (location == null) {
|
||||
throw new IOException("Redirection response without Location header");
|
||||
}
|
||||
// Resolve location against CURRENT URL (which has the hostname)
|
||||
currentUrl = uri.resolve(location).toString();
|
||||
redirectCount++;
|
||||
} else {
|
||||
throw new IOException("Failed to download image. HTTP Status: " + response.getStatusCode());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Ensure thread-local is cleared to prevent leakage to subsequent requests (container thread reuse)
|
||||
clearTargetHost();
|
||||
}
|
||||
|
||||
throw new IOException("Too many redirects (max " + MAX_REDIRECTS + ")");
|
||||
}
|
||||
|
||||
private boolean isInternalAddress(InetAddress address) {
|
||||
if (address.isLoopbackAddress() || address.isLinkLocalAddress() ||
|
||||
address.isSiteLocalAddress() || address.isAnyLocalAddress()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
byte[] addr = address.getAddress();
|
||||
// Check for IPv6 Unique Local Address (fc00::/7)
|
||||
if (addr.length == 16) {
|
||||
if ((addr[0] & 0xFE) == (byte) 0xFC) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
|
||||
if (isIpv4MappedAddress(addr)) {
|
||||
try {
|
||||
byte[] ipv4Bytes = new byte[4];
|
||||
System.arraycopy(addr, 12, ipv4Bytes, 0, 4);
|
||||
InetAddress ipv4Addr = InetAddress.getByAddress(ipv4Bytes);
|
||||
return isInternalAddress(ipv4Addr);
|
||||
} catch (java.net.UnknownHostException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isIpv4MappedAddress(byte[] addr) {
|
||||
if (addr.length != 16) return false;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (addr[i] != 0) return false;
|
||||
}
|
||||
return (addr[10] == (byte) 0xFF) && (addr[11] == (byte) 0xFF);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// COVER OPERATIONS
|
||||
// ========================================
|
||||
|
||||
@@ -12,7 +12,7 @@ import javax.xml.parsers.ParserConfigurationException;
|
||||
@UtilityClass
|
||||
public class SecureXmlUtils {
|
||||
|
||||
public static DocumentBuilderFactory createSecureDocumentBuilderFactory(boolean namespaceAware) {
|
||||
public static DocumentBuilderFactory createSecureDocumentBuilderFactory(boolean namespaceAware) throws ParserConfigurationException {
|
||||
try {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(namespaceAware);
|
||||
@@ -27,10 +27,8 @@ public class SecureXmlUtils {
|
||||
|
||||
return factory;
|
||||
} catch (ParserConfigurationException e) {
|
||||
log.warn("Failed to configure secure XML parser, using defaults: {}", e.getMessage());
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(namespaceAware);
|
||||
return factory;
|
||||
log.error("CRITICAL: Failed to configure secure XML parser: {}", e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@ app:
|
||||
path-config: '/app/data'
|
||||
bookdrop-folder: '/bookdrop'
|
||||
version: 'development'
|
||||
|
||||
cors:
|
||||
# Comma-separated list of allowed CORS origins.
|
||||
# Leave empty (default) to allow same-origin requests only.
|
||||
# Example: ALLOWED_ORIGINS=https://app.example.com,https://mobile.example.com
|
||||
allowed-origins: ${ALLOWED_ORIGINS:*}
|
||||
|
||||
remote-auth:
|
||||
enabled: ${REMOTE_AUTH_ENABLED:false}
|
||||
create-new-users: ${REMOTE_AUTH_CREATE_NEW_USERS:true}
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
@@ -61,7 +62,9 @@ class FileServiceTest {
|
||||
.build();
|
||||
lenient().when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
fileService = new FileService(appProperties, mock(RestTemplate.class), appSettingService);
|
||||
RestTemplate mockRestTemplate = mock(RestTemplate.class);
|
||||
RestTemplate mockNoRedirectRestTemplate = mock(RestTemplate.class);
|
||||
fileService = new FileService(appProperties, mockRestTemplate, appSettingService, mockNoRedirectRestTemplate);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -374,9 +377,9 @@ class FileServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidData_returnsNull() throws IOException {
|
||||
void invalidData_throwsException() {
|
||||
byte[] invalidData = "not an image".getBytes();
|
||||
assertNull(FileService.readImage(invalidData));
|
||||
assertThrows(IOException.class, () -> FileService.readImage(invalidData));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,11 +488,11 @@ class FileServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidImageData_skipsSave() {
|
||||
void invalidImageData_throwsException() {
|
||||
byte[] invalidData = "not an image".getBytes();
|
||||
Path outputPath = tempDir.resolve("invalid.jpg");
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
assertThrows(IOException.class, () ->
|
||||
FileService.saveImage(invalidData, outputPath.toString()));
|
||||
assertFalse(Files.exists(outputPath));
|
||||
}
|
||||
@@ -913,12 +916,11 @@ class FileServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidImageBytes_skipsThumbnail() {
|
||||
void invalidImageBytes_throwsException() {
|
||||
byte[] invalidData = "not an image".getBytes();
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
assertThrows(RuntimeException.class, () ->
|
||||
fileService.createThumbnailFromBytes(16L, invalidData));
|
||||
assertFalse(Files.exists(Path.of(fileService.getCoverFile(16L))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1031,15 +1033,14 @@ class FileServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void corruptImageData_skipsThumbnail() {
|
||||
void corruptImageData_throwsException() {
|
||||
// Valid MIME type but corrupt image data
|
||||
byte[] corruptData = ("not an image but has jpeg mime type").getBytes();
|
||||
MockMultipartFile corruptFile = new MockMultipartFile(
|
||||
"file", "corrupt.jpg", "image/jpeg", corruptData);
|
||||
|
||||
assertDoesNotThrow(() ->
|
||||
assertThrows(RuntimeException.class, () ->
|
||||
fileService.createThumbnailFromFile(12L, corruptFile));
|
||||
assertFalse(Files.exists(Path.of(fileService.getCoverFile(12L))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1176,7 +1177,7 @@ class FileServiceTest {
|
||||
.build();
|
||||
lenient().when(appSettingServiceForNetwork.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
fileService = new FileService(appProperties, restTemplate, appSettingServiceForNetwork);
|
||||
fileService = new FileService(appProperties, restTemplate, appSettingServiceForNetwork, restTemplate);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -1187,17 +1188,17 @@ class FileServiceTest {
|
||||
@DisplayName("downloads and returns valid image")
|
||||
@Timeout(5)
|
||||
void downloadImageFromUrl_validImage_returnsBufferedImage() throws IOException {
|
||||
String imageUrl = "http://example.com/image.jpg";
|
||||
String imageUrl = "http://1.1.1.1/image.jpg";
|
||||
BufferedImage testImage = createTestImage(100, 100);
|
||||
byte[] imageBytes = imageToBytes(testImage);
|
||||
|
||||
RestTemplate mockRestTemplate = mock(RestTemplate.class);
|
||||
AppSettingService mockAppSettingService = mock(AppSettingService.class);
|
||||
FileService testFileService = new FileService(appProperties, mockRestTemplate, mockAppSettingService);
|
||||
FileService testFileService = new FileService(appProperties, mockRestTemplate, mockAppSettingService, mockRestTemplate);
|
||||
|
||||
ResponseEntity<byte[]> responseEntity = ResponseEntity.ok(imageBytes);
|
||||
when(mockRestTemplate.exchange(
|
||||
eq(imageUrl),
|
||||
anyString(),
|
||||
eq(HttpMethod.GET),
|
||||
any(HttpEntity.class),
|
||||
eq(byte[].class)
|
||||
@@ -1214,10 +1215,10 @@ class FileServiceTest {
|
||||
@DisplayName("throws exception when response body is null")
|
||||
@Timeout(5)
|
||||
void downloadImageFromUrl_nullBody_throwsException() {
|
||||
String imageUrl = "http://example.com/image.jpg";
|
||||
String imageUrl = "http://1.1.1.1/image.jpg";
|
||||
ResponseEntity<byte[]> responseEntity = ResponseEntity.ok(null);
|
||||
when(restTemplate.exchange(
|
||||
eq(imageUrl),
|
||||
anyString(),
|
||||
eq(HttpMethod.GET),
|
||||
any(HttpEntity.class),
|
||||
eq(byte[].class)
|
||||
@@ -1228,31 +1229,38 @@ class FileServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns null when ImageIO cannot read bytes")
|
||||
@DisplayName("throws IOException when ImageIO cannot read bytes")
|
||||
@Timeout(5)
|
||||
void downloadImageFromUrl_invalidImageData_returnsNull() throws IOException {
|
||||
String imageUrl = "http://example.com/image.jpg";
|
||||
void downloadImageFromUrl_invalidImageData_throwsException() throws IOException {
|
||||
String imageUrl = "http://1.1.1.1/image.jpg";
|
||||
byte[] invalidBytes = "not an image".getBytes();
|
||||
ResponseEntity<byte[]> responseEntity = ResponseEntity.ok(invalidBytes);
|
||||
when(restTemplate.exchange(
|
||||
eq(imageUrl),
|
||||
// Note: using ReflectionTestUtils to get the private mock if needed,
|
||||
// but wait, setup() already created fileService with mocks.
|
||||
// We just need to know which mock to use.
|
||||
// The setup() creates and injects mockNoRedirectRestTemplate.
|
||||
|
||||
// Let's use ReflectionTestUtils to mock the correct one since the field in test class is 'restTemplate'
|
||||
RestTemplate noRedirectMock = (RestTemplate) ReflectionTestUtils.getField(fileService, "noRedirectRestTemplate");
|
||||
|
||||
when(noRedirectMock.exchange(
|
||||
anyString(),
|
||||
eq(HttpMethod.GET),
|
||||
any(HttpEntity.class),
|
||||
eq(byte[].class)
|
||||
)).thenReturn(responseEntity);
|
||||
|
||||
BufferedImage result = fileService.downloadImageFromUrl(imageUrl);
|
||||
assertNull(result);
|
||||
assertThrows(IOException.class, () -> fileService.downloadImageFromUrl(imageUrl));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("throws exception on HTTP error status")
|
||||
@Timeout(5)
|
||||
void downloadImageFromUrl_httpError_throwsException() {
|
||||
String imageUrl = "http://example.com/image.jpg";
|
||||
String imageUrl = "http://1.1.1.1/image.jpg";
|
||||
ResponseEntity<byte[]> responseEntity = ResponseEntity.notFound().build();
|
||||
when(restTemplate.exchange(
|
||||
eq(imageUrl),
|
||||
anyString(),
|
||||
eq(HttpMethod.GET),
|
||||
any(HttpEntity.class),
|
||||
eq(byte[].class)
|
||||
@@ -1271,14 +1279,14 @@ class FileServiceTest {
|
||||
@DisplayName("downloads and saves cover images successfully")
|
||||
@Timeout(5)
|
||||
void createThumbnailFromUrl_validImage_createsCoverAndThumbnail() throws IOException {
|
||||
String imageUrl = "http://example.com/cover.jpg";
|
||||
String imageUrl = "http://1.1.1.1/cover.jpg";
|
||||
long bookId = 42L;
|
||||
BufferedImage testImage = createTestImage(800, 1200); // Portrait image
|
||||
byte[] imageBytes = imageToBytes(testImage);
|
||||
|
||||
ResponseEntity<byte[]> responseEntity = ResponseEntity.ok(imageBytes);
|
||||
when(restTemplate.exchange(
|
||||
eq(imageUrl),
|
||||
anyString(),
|
||||
eq(HttpMethod.GET),
|
||||
any(HttpEntity.class),
|
||||
eq(byte[].class)
|
||||
@@ -1304,7 +1312,7 @@ class FileServiceTest {
|
||||
long bookId = 42L;
|
||||
|
||||
when(restTemplate.exchange(
|
||||
eq(imageUrl),
|
||||
anyString(),
|
||||
eq(HttpMethod.GET),
|
||||
any(HttpEntity.class),
|
||||
eq(byte[].class)
|
||||
|
||||
750
booklore-ui/package-lock.json
generated
750
booklore-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@
|
||||
"@angular/service-worker": "^21.1.5",
|
||||
"@iharbeck/ngx-virtual-scroller": "^20.0.0",
|
||||
"@jsverse/transloco": "^8.2.1",
|
||||
"@primeng/themes": "^21.0.3",
|
||||
"@primeuix/themes": "^2.0.3",
|
||||
"@stomp/rx-stomp": "^2.3.0",
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
@@ -33,6 +33,7 @@
|
||||
"chartjs-chart-matrix": "^3.0.0",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"ng-lazyload-image": "^9.1.3",
|
||||
"ng2-charts": "^8.0.0",
|
||||
@@ -44,25 +45,32 @@
|
||||
"rxjs": "^7.8.2",
|
||||
"showdown": "^2.1.0",
|
||||
"tslib": "^2.8.1",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^11.0.0",
|
||||
"ws": "^8.19.0",
|
||||
"zone.js": "^0.16.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@analogjs/vite-plugin-angular": "^2.2.3",
|
||||
"@analogjs/vitest-angular": "^2.2.3",
|
||||
"@angular/build": "^21.1.4",
|
||||
"@angular/cli": "^21.1.4",
|
||||
"@angular/build": "^21.1.5",
|
||||
"@angular/cli": "^21.1.5",
|
||||
"@angular/compiler-cli": "^21.1.5",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/showdown": "^2.0.6",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"angular-eslint": "^21.2.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint": "^9.39.3",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.1",
|
||||
"ajv": "^8.18.0",
|
||||
"eslint": {
|
||||
"ajv": "^6.14.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Aura from '@primeng/themes/aura';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
|
||||
type ColorPalette = Record<string, string>;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {DOCUMENT, isPlatformBrowser} from '@angular/common';
|
||||
import {effect, inject, Injectable, PLATFORM_ID, signal} from '@angular/core';
|
||||
import {$t, updatePreset, updateSurfacePalette} from '@primeng/themes';
|
||||
import Aura from '@primeng/themes/aura';
|
||||
import {AppState} from '../model/app-state.model';
|
||||
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||
import { effect, inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
|
||||
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import { AppState } from '../model/app-state.model';
|
||||
|
||||
type ColorPalette = Record<string, string>;
|
||||
|
||||
@@ -332,7 +332,7 @@ export class AppConfigService {
|
||||
|
||||
constructor() {
|
||||
const initialState = this.loadAppState();
|
||||
this.appState.set({...initialState});
|
||||
this.appState.set({ ...initialState });
|
||||
this.document.documentElement.classList.add('p-dark');
|
||||
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
@@ -383,7 +383,7 @@ export class AppConfigService {
|
||||
if (primaryName === 'noir') {
|
||||
return {
|
||||
semantic: {
|
||||
primary: {...surfacePalette},
|
||||
primary: { ...surfacePalette },
|
||||
colorScheme: {
|
||||
dark: {
|
||||
primary: {
|
||||
@@ -430,6 +430,6 @@ export class AppConfigService {
|
||||
onPresetChange(): void {
|
||||
const surfacePalette = this.getSurfacePalette(this.appState().surface ?? 'neutral');
|
||||
const preset = this.getPresetExt();
|
||||
$t().preset(Aura).preset(preset).surfacePalette(surfacePalette).use({useDefaultOptions: true});
|
||||
$t().preset(Aura).preset(preset).surfacePalette(surfacePalette).use({ useDefaultOptions: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable, of} from 'rxjs';
|
||||
import {finalize, map, shareReplay, tap} from 'rxjs/operators';
|
||||
import {API_CONFIG} from '../../core/config/api-config';
|
||||
import {IconCacheService} from './icon-cache.service';
|
||||
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { finalize, map, shareReplay, tap } from 'rxjs/operators';
|
||||
import { API_CONFIG } from '../../core/config/api-config';
|
||||
import { IconCacheService } from './icon-cache.service';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface SvgIconData {
|
||||
svgName: string;
|
||||
@@ -25,7 +26,6 @@ interface SvgIconBatchResponse {
|
||||
}
|
||||
|
||||
type IconContentMap = Record<string, string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -39,6 +39,16 @@ export class IconService {
|
||||
private iconCache = inject(IconCacheService);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
/**
|
||||
* Sanitizes SVG content using DOMPurify.
|
||||
*/
|
||||
private sanitizeSvgContent(content: string): string {
|
||||
return DOMPurify.sanitize(content, {
|
||||
USE_PROFILES: { svg: true },
|
||||
FORBID_TAGS: ['script', 'style', 'foreignObject']
|
||||
});
|
||||
}
|
||||
|
||||
preloadAllIcons(): Observable<void> {
|
||||
if (this.preloadCache$) {
|
||||
return this.preloadCache$;
|
||||
@@ -47,12 +57,12 @@ export class IconService {
|
||||
this.preloadCache$ = this.http.get<IconContentMap>(`${this.baseUrl}/all/content`).pipe(
|
||||
tap((iconsMap) => {
|
||||
Object.entries(iconsMap).forEach(([iconName, content]) => {
|
||||
const sanitized = this.sanitizer.bypassSecurityTrustHtml(content);
|
||||
const sanitized = this.sanitizer.bypassSecurityTrustHtml(this.sanitizeSvgContent(content));
|
||||
this.iconCache.cacheIcon(iconName, content, sanitized);
|
||||
});
|
||||
}),
|
||||
map(() => void 0),
|
||||
shareReplay({bufferSize: 1, refCount: false}),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
finalize(() => this.preloadCache$ = null)
|
||||
);
|
||||
|
||||
@@ -70,10 +80,10 @@ export class IconService {
|
||||
responseType: 'text'
|
||||
}).pipe(
|
||||
tap(content => {
|
||||
const sanitized = this.sanitizer.bypassSecurityTrustHtml(content);
|
||||
const sanitized = this.sanitizer.bypassSecurityTrustHtml(this.sanitizeSvgContent(content));
|
||||
this.iconCache.cacheIcon(iconName, content, sanitized);
|
||||
}),
|
||||
shareReplay({bufferSize: 1, refCount: true}),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
finalize(() => this.requestCache.delete(iconName))
|
||||
);
|
||||
|
||||
@@ -113,13 +123,13 @@ export class IconService {
|
||||
}
|
||||
|
||||
saveBatchSvgIcons(icons: SvgIconData[]): Observable<SvgIconBatchResponse> {
|
||||
return this.http.post<SvgIconBatchResponse>(`${this.baseUrl}/batch`, {icons}).pipe(
|
||||
return this.http.post<SvgIconBatchResponse>(`${this.baseUrl}/batch`, { icons }).pipe(
|
||||
tap((response) => {
|
||||
response.results.forEach(result => {
|
||||
if (result.success) {
|
||||
const iconData = icons.find(icon => icon.svgName === result.iconName);
|
||||
if (iconData) {
|
||||
const sanitized = this.sanitizer.bypassSecurityTrustHtml(iconData.svgData);
|
||||
const sanitized = this.sanitizer.bypassSecurityTrustHtml(this.sanitizeSvgContent(iconData.svgData));
|
||||
this.iconCache.cacheIcon(iconData.svgName, iconData.svgData, sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import {provideHttpClient, withInterceptors} from '@angular/common/http';
|
||||
import {DialogService} from 'primeng/dynamicdialog';
|
||||
import {ConfirmationService, MessageService} from 'primeng/api';
|
||||
import {RxStompService} from './app/shared/websocket/rx-stomp.service';
|
||||
import {rxStompServiceFactory} from './app/shared/websocket/rx-stomp-service-factory';
|
||||
import {provideRouter, RouteReuseStrategy} from '@angular/router';
|
||||
import {CustomReuseStrategy} from './app/core/custom-reuse-strategy';
|
||||
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
|
||||
import {providePrimeNG} from 'primeng/config';
|
||||
import {bootstrapApplication} from '@angular/platform-browser';
|
||||
import {AppComponent} from './app/app.component';
|
||||
import Aura from '@primeng/themes/aura';
|
||||
import {routes} from './app/app.routes';
|
||||
import {AuthInterceptorService} from './app/core/security/auth-interceptor.service';
|
||||
import {AuthService, websocketInitializer} from './app/shared/service/auth.service';
|
||||
import {OAuthStorage, provideOAuthClient} from 'angular-oauth2-oidc';
|
||||
import {inject, isDevMode, provideAppInitializer, provideZoneChangeDetection} from '@angular/core';
|
||||
import {initializeAuthFactory} from './app/core/security/auth-initializer';
|
||||
import {StartupService} from './app/shared/service/startup.service';
|
||||
import {provideCharts, withDefaultRegisterables} from 'ng2-charts';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { DialogService } from 'primeng/dynamicdialog';
|
||||
import { ConfirmationService, MessageService } from 'primeng/api';
|
||||
import { RxStompService } from './app/shared/websocket/rx-stomp.service';
|
||||
import { rxStompServiceFactory } from './app/shared/websocket/rx-stomp-service-factory';
|
||||
import { provideRouter, RouteReuseStrategy } from '@angular/router';
|
||||
import { CustomReuseStrategy } from './app/core/custom-reuse-strategy';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { providePrimeNG } from 'primeng/config';
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import { routes } from './app/app.routes';
|
||||
import { AuthInterceptorService } from './app/core/security/auth-interceptor.service';
|
||||
import { AuthService, websocketInitializer } from './app/shared/service/auth.service';
|
||||
import { OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
|
||||
import { inject, isDevMode, provideAppInitializer, provideZoneChangeDetection } from '@angular/core';
|
||||
import { initializeAuthFactory } from './app/core/security/auth-initializer';
|
||||
import { StartupService } from './app/shared/service/startup.service';
|
||||
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import {provideServiceWorker} from '@angular/service-worker';
|
||||
import {provideTransloco} from '@jsverse/transloco';
|
||||
import {AVAILABLE_LANGS, TranslocoInlineLoader} from './app/core/config/transloco-loader';
|
||||
import {initializeLanguage} from './app/core/config/language-initializer';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { provideTransloco } from '@jsverse/transloco';
|
||||
import { AVAILABLE_LANGS, TranslocoInlineLoader } from './app/core/config/transloco-loader';
|
||||
import { initializeLanguage } from './app/core/config/language-initializer';
|
||||
|
||||
export function storageFactory(): OAuthStorage {
|
||||
return localStorage;
|
||||
|
||||
@@ -4,12 +4,13 @@ services:
|
||||
command: sh -c "cd /booklore-api && ./gradlew bootRun"
|
||||
ports:
|
||||
- "${BACKEND_PORT:-6060}:6060"
|
||||
- "${REMOTE_DEBUG_PORT:-5005}:5005"
|
||||
- "127.0.0.1:${REMOTE_DEBUG_PORT:-5005}:5005"
|
||||
environment:
|
||||
- DATABASE_URL=jdbc:mariadb://backend_db:3306/booklore
|
||||
- DATABASE_USERNAME=booklore
|
||||
- DATABASE_PASSWORD=booklore
|
||||
- REMOTE_DEBUG_ENABLED=true
|
||||
- ALLOWED_ORIGINS=http://localhost:4200
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
- DATABASE_PASSWORD=your_secure_password # Use a strong password; must match MYSQL_PASSWORD defined in the mariadb container
|
||||
- SWAGGER_ENABLED=false # Enable or disable Swagger UI (API docs). Set to 'true' to allow access; 'false' to block access (recommended for production).
|
||||
- FORCE_DISABLE_OIDC=false # Set to 'true' to force-disable OIDC and allow internal login, regardless of UI config
|
||||
# ALLOWED_ORIGINS= # Comma-separated list of allowed cross-origin URLs for CORS (e.g. https://app.example.com). Defaults to '*' (allow all) if unset. Leave empty to restrict to same-origin.
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
|
||||
Reference in New Issue
Block a user