From 41b67daf0b69ca2d3b15925f6be9ab6170d47bc6 Mon Sep 17 00:00:00 2001 From: Yevhen Salitrynskyi Date: Sun, 19 Jul 2026 10:04:00 -0400 Subject: [PATCH 1/6] feat(server): fail closed on secrets, origins, and forwarded headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosting a clipboard relay means the server holds every device's traffic, so the defaults have to be safe before anything else matters. - The H2 password no longer has a baked-in default, and startup fails without CC_SERVER_DB_PASSWORD. Detected by excluding jdbc:h2:mem: rather than by matching a jdbc:h2:file: prefix, so the other on-disk URL forms (jdbc:h2:~/x, ./x, /abs) cannot boot with an empty password — which, with CIPHER=AES, also meant the file was not meaningfully encrypted. - CC_ALLOWED_ORIGINS=* refuses to start. A wildcard origin is applied verbatim to both WebSocket endpoints, so any site a user visits could open a socket with their session cookie and read their clipboard. - X-Forwarded-For is honoured only from CC_TRUSTED_PROXY_CIDRS, which is empty by default. All header values are joined before the chain is walked right-to-left: getHeader() returns only the first, which is the client's own line when a proxy appends rather than replaces. Hops are canonicalised through InetAddress and rejected unless they are IP literals, so one host cannot occupy several brute-force buckets by varying spelling (203.0.113.050, ::::, 1.2.3.4:5678) or turn an inbound header into a DNS lookup. An unusable right-most hop falls back to the socket peer instead of walking further left into client-written entries. - CSP names the configured origins in connect-src; bare "ws: wss:" allowed a socket to any host. - The P2P announce branch requires a server-assigned peer id, so a session racing registration cannot announce itself as another device. - Container runs as uid 10001 with /database and /logs chowned. The resolver's configuration is now passed in rather than read from the environment inside the resolution logic, which is what finally made the trusted-proxy path testable: every previous test ran with the header ignored. Server tests 0 -> 26. --- .../ClipCascade_Backend/.dockerignore | 7 + .../ClipCascade_Backend/Dockerfile | 17 +- .../config/ClipCascadeProperties.java | 112 ++++++- .../config/P2PWebSocketConfig.java | 2 +- .../config/P2PWebSocketHandler.java | 83 ++++- .../config/SecurityConfiguration.java | 41 ++- .../config/StompWebSocketConfig.java | 2 +- .../constants/IpResolverConstants.java | 27 +- .../constants/ServerConstants.java | 2 +- .../controller/ClipCascadeController.java | 25 +- .../clipcascade/service/DonationService.java | 13 +- .../service/FacadeUserService.java | 61 +++- .../acme/clipcascade/service/UserService.java | 9 + .../clipcascade/utils/IpAddressResolver.java | 288 +++++++++++++++++- .../clipcascade/utils/ResponseEntityUtil.java | 12 +- .../acme/clipcascade/utils/UserValidator.java | 11 +- .../src/main/resources/application.properties | 4 +- .../ClipCascadeApplicationTests.java | 13 +- .../config/ClipCascadePropertiesTest.java | 69 +++++ .../controller/ClipCascadeRelayTest.java | 137 +++++++++ .../service/FacadeUserServiceTest.java | 91 ++++++ .../utils/IpAddressResolverTest.java | 185 +++++++++++ 22 files changed, 1151 insertions(+), 60 deletions(-) create mode 100644 ClipCascade_Server/ClipCascade_Backend/.dockerignore create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/config/ClipCascadePropertiesTest.java create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/controller/ClipCascadeRelayTest.java create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/service/FacadeUserServiceTest.java create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/utils/IpAddressResolverTest.java diff --git a/ClipCascade_Server/ClipCascade_Backend/.dockerignore b/ClipCascade_Server/ClipCascade_Backend/.dockerignore new file mode 100644 index 000000000..6528c6e5f --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/.dockerignore @@ -0,0 +1,7 @@ +database/ +logs/ +.git/ +.mvn/wrapper/maven-wrapper.jar +*.db +*.log +*.env diff --git a/ClipCascade_Server/ClipCascade_Backend/Dockerfile b/ClipCascade_Server/ClipCascade_Backend/Dockerfile index 189382d8a..66b493c0e 100644 --- a/ClipCascade_Server/ClipCascade_Backend/Dockerfile +++ b/ClipCascade_Server/ClipCascade_Backend/Dockerfile @@ -4,6 +4,14 @@ # ------------------------- FROM eclipse-temurin:21-jre-jammy +RUN groupadd --system --gid 10001 clipcascade \ + && useradd --system --uid 10001 --gid clipcascade --home-dir /nonexistent --shell /usr/sbin/nologin clipcascade \ + && apt-get update \ + && apt-get install -y --no-install-recommends wget \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /database /logs \ + && chown -R clipcascade:clipcascade /database /logs + # # ------------------------- # # 1) Install prerequisites @@ -32,7 +40,7 @@ FROM eclipse-temurin:21-jre-jammy # ------------------------- # 4) Copy and expose clipcascade app # ------------------------- -COPY target/*.jar app.jar +COPY --chown=clipcascade:clipcascade target/*.jar /app.jar EXPOSE 8080 # # ------------------------- @@ -42,4 +50,9 @@ EXPOSE 8080 # if [ \"${CC_EXTERNAL_BROKER_ENABLED:-false}\" = \"true\" ]; then \ # /opt/activemq/bin/activemq start; \ # fi && exec java -jar /app.jar"] -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +USER 10001:10001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/health | grep -q OK || exit 1 + +ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java index 165eb0484..321bff62a 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java @@ -3,6 +3,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; +import jakarta.annotation.PostConstruct; + @Configuration public class ClipCascadeProperties { @@ -21,8 +23,8 @@ public class ClipCascadeProperties { @Value("${CC_MAX_MESSAGE_SIZE_IN_BYTES:0}") private long maxMessageSizeInBytes; - // Allowed origins for WebSocket connections (default: all origins '*') - @Value("${CC_ALLOWED_ORIGINS:*}") + // Allowed origins for WebSocket connections + @Value("${CC_ALLOWED_ORIGINS:http://localhost:8080}") private String allowedOrigins; // Flag to enable or disable signup form (default: false) @@ -188,13 +190,13 @@ public class ClipCascadeProperties { private String serverDbUsername; /* - * Server database host (default: QjuGlhE3uwylBBANMkX1 o2MdEoFgbU5XkFvTftky) + * Server database password. * note: Ensure configuration is included in the application.properties file as * well. * * and are for h2 file database */ - @Value("${CC_SERVER_DB_PASSWORD:QjuGlhE3uwylBBANMkX1 o2MdEoFgbU5XkFvTftky}") + @Value("${CC_SERVER_DB_PASSWORD:}") private String serverDbPassword; /* @@ -231,11 +233,11 @@ public class ClipCascadeProperties { private int port; /* - * Server Session timeout (default: 525960m) + * Server Session timeout (default: 1440m) * note: Ensure configuration is included in the application.properties file as * well. */ - @Value("${CC_SESSION_TIMEOUT:525960m}") + @Value("${CC_SESSION_TIMEOUT:1440m}") private String sessionTimeout; /* @@ -277,6 +279,68 @@ public class ClipCascadeProperties { @Value("${CC_DONATIONS_ENABLED:false}") private boolean donationsEnabled; + @Value("${CC_INITIAL_ADMIN_USERNAME:admin}") + private String initialAdminUsername; + + @Value("${CC_INITIAL_ADMIN_PASSWORD:}") + private String initialAdminPassword; + + @Value("${CC_UPDATE_CHECK_ENABLED:true}") + private boolean updateCheckEnabled; + + @PostConstruct + void validateRequiredSecrets() { + if (isH2FileDatabase() && !isServerDbPasswordConfigured()) { + throw new IllegalStateException( + "Set CC_SERVER_DB_PASSWORD before starting an H2 file database."); + } + if (isWildcardOrigin()) { + throw new IllegalStateException( + "CC_ALLOWED_ORIGINS=* is not supported. A wildcard origin lets any website " + + "open an authenticated WebSocket to this server using the visitor's " + + "session cookie and read their clipboard. Set exact origins instead, " + + "comma separated, e.g. " + + "CC_ALLOWED_ORIGINS=http://10.0.0.5:8080,https://host.example.ts.net"); + } + } + + /** + * A wildcard is rejected outright rather than narrowed, because it is + * applied verbatim to both WebSocket endpoints and cannot be made safe + * while credentials are in play. + */ + private boolean isWildcardOrigin() { + if (allowedOrigins == null) { + return false; + } + return java.util.Arrays.stream(allowedOrigins.split(",")) + .map(String::trim) + .anyMatch(origin -> "*".equals(origin)); + } + + private boolean isServerDbPasswordConfigured() { + return serverDbPassword != null && !serverDbPassword.isBlank(); + } + + /** + * True for any on-disk H2 database. + * + * Detected by exclusion rather than by matching "jdbc:h2:file:": H2 also + * accepts jdbc:h2:~/x, jdbc:h2:./x and jdbc:h2:/abs, which are equally + * persistent. A prefix match let those boot with an empty password — and + * since the shipped URL carries CIPHER=AES, an empty password also means + * the file is not meaningfully encrypted. + */ + private boolean isH2FileDatabase() { + String url = serverDbUrl == null ? "" : serverDbUrl.trim().toLowerCase(); + String driver = serverDbDriver == null ? "" : serverDbDriver.trim().toLowerCase(); + if (!driver.contains("h2") || !url.startsWith("jdbc:h2:")) { + return false; + } + // In-memory databases are ephemeral and used by the test suite. + return !url.startsWith("jdbc:h2:mem:"); + } + private long getMessageSizeInBytes() { /* * Note: Ensure that the same logic is applied in the activemq.xml file as well. @@ -307,6 +371,21 @@ public String getAllowedOrigins() { return allowedOrigins; } + public String[] getAllowedOriginsArray() { + if (allowedOrigins == null || allowedOrigins.isBlank()) { + return new String[] { "http://localhost:8080" }; + } + + // Defence in depth: validateRequiredSecrets() already fails startup on a + // wildcard, but this method is also reachable from tests and any future + // caller that builds the properties directly. + return java.util.Arrays.stream(allowedOrigins.split(",")) + .map(String::trim) + .filter(origin -> !origin.isBlank()) + .filter(origin -> !"*".equals(origin)) + .toArray(String[]::new); + } + public boolean isSignupEnabled() { return signupEnabled; } @@ -459,6 +538,26 @@ public boolean getDonationsEnabled() { return donationsEnabled; } + public String getInitialAdminUsername() { + return initialAdminUsername; + } + + public String getInitialAdminPassword() { + return initialAdminPassword; + } + + public boolean isInitialAdminPasswordConfigured() { + return initialAdminPassword != null && !initialAdminPassword.isBlank(); + } + + public boolean isUpdateCheckEnabled() { + return updateCheckEnabled; + } + + public boolean getUpdateCheckEnabled() { + return updateCheckEnabled; + } + @Override public String toString() { return "{\n" + @@ -492,6 +591,7 @@ public String toString() { ",\n p2pStunUrl='" + getP2pStunUrl() + "'" + ",\n maxWsGlobalConnections='" + getMaxWsGlobalConnections() + "'" + ",\n maxWsConnectionsPerUser='" + getMaxWsConnectionsPerUser() + "'" + + ",\n updateCheckEnabled='" + isUpdateCheckEnabled() + "'" + "\n}"; } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java index 74fcc796f..75f0d361d 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java @@ -26,6 +26,6 @@ public P2PWebSocketConfig( @Override public void registerWebSocketHandlers(@NonNull WebSocketHandlerRegistry registry) { registry.addHandler(p2pWebSocketHandler, "/p2psignaling") - .setAllowedOrigins(clipCascadeProperties.getAllowedOrigins()); + .setAllowedOrigins(clipCascadeProperties.getAllowedOriginsArray()); } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java index 62b9dcaae..26468f902 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java @@ -28,6 +28,7 @@ import com.acme.clipcascade.utils.TimeUtility; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import ch.qos.logback.classic.Logger; import jakarta.annotation.PreDestroy; @@ -35,6 +36,7 @@ @Component @ConditionalOnProperty(prefix = "app.p2p", name = "enabled", havingValue = "true", matchIfMissing = false) public class P2PWebSocketHandler extends AbstractWebSocketHandler { + private static final int MAX_PEER_ID_LENGTH = 64; private final ObjectMapper objectMapper; private final Logger logger; @@ -250,16 +252,49 @@ protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull Tex String type = json.has("type") ? json.get("type").asText() : ""; String toPeerId = json.has("toPeerId") ? json.get("toPeerId").asText() : null; + String fromPeerId = userSessionsUUID.get(session.getId()); + + // Room-wide device announce / pairing (signed by clients; server only relays). + if ("DEVICE_ANNOUNCE".equals(type) || "PAIR_REQUEST".equals(type) + || "PAIR_ACCEPT".equals(type) || "PAIR_REJECT".equals(type)) { + // Server-assigned identity only. If this session has no peer id + // yet (it can be racing registration), drop the message rather + // than relaying the sender's own fromPeerId/peerId, which would + // let it announce itself as another device. + if (!isValidPeerId(fromPeerId)) { + return; + } + ObjectNode outbound = json.deepCopy(); + outbound.put("fromPeerId", fromPeerId); + outbound.put("peerId", fromPeerId); + TextMessage broadcast = new TextMessage(objectMapper.writeValueAsString(outbound)); + for (Map.Entry entry : userSessions.entrySet()) { + if (!entry.getKey().equals(session.getId())) { + sendMessage(entry.getValue(), broadcast); + } + } + return; + } if ("OFFER".equals(type) || "ANSWER".equals(type) || "ICE_CANDIDATE".equals(type)) { - // Forward to the correct session within this user's room - if (toPeerId != null) { - String targetSessionId = MapUtility.getKeyByValue(userSessionsUUID, toPeerId); - if (targetSessionId != null) { - WebSocketSession targetSession = userSessions.get(targetSessionId); - sendMessage(targetSession, message); - } + if (!isValidPeerId(toPeerId) || !isValidPeerId(fromPeerId)) { + return; + } + + String targetSessionId = MapUtility.getKeyByValue(userSessionsUUID, toPeerId); + if (targetSessionId == null || targetSessionId.equals(session.getId())) { + return; } + + WebSocketSession targetSession = userSessions.get(targetSessionId); + if (targetSession == null) { + return; + } + + ObjectNode outbound = json.deepCopy(); + outbound.put("fromPeerId", fromPeerId); + outbound.put("toPeerId", toPeerId); + sendMessage(targetSession, new TextMessage(objectMapper.writeValueAsString(outbound))); } } finally { lock.unlock(); // release the lock @@ -293,6 +328,40 @@ public void shutdown() { } } + public void closeSessionsForUser(String username) { + if (username == null || username.isBlank()) { + return; + } + + Map userSessions = sessions.get(username); + if (userSessions == null) { + return; + } + + for (WebSocketSession session : userSessions.values()) { + try { + if (session != null && session.isOpen()) { + session.close(CloseStatus.POLICY_VIOLATION); + } + } catch (Exception e) { + logger.debug("Failed to close WebSocket session(P2P) for user {}: {}", username, e.getMessage()); + } + } + } + + private boolean isValidPeerId(String peerId) { + if (peerId == null || peerId.isBlank() || peerId.length() > MAX_PEER_ID_LENGTH) { + return false; + } + + try { + UUID.fromString(peerId); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + private void sendMessage(WebSocketSession session, TextMessage message) { if (session == null || !session.isOpen()) { return; diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java index cfac30269..2be17168d 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java @@ -24,17 +24,45 @@ public class SecurityConfiguration { private final BCryptPasswordEncoder bCryptPasswordEncoder; private final BruteForceProtectionService bruteForceProtectionService; private final FacadeUserService facadeUserService; + private final ClipCascadeProperties clipCascadeProperties; SecurityConfiguration( UserDetailsService userDetailsService, BCryptPasswordEncoder bCryptPasswordEncoder, BruteForceProtectionService bruteForceProtectionService, - FacadeUserService facadeUserService) { + FacadeUserService facadeUserService, + ClipCascadeProperties clipCascadeProperties) { this.userDetailsService = userDetailsService; this.bCryptPasswordEncoder = bCryptPasswordEncoder; this.bruteForceProtectionService = bruteForceProtectionService; this.facadeUserService = facadeUserService; + this.clipCascadeProperties = clipCascadeProperties; + } + + /** + * connect-src value: 'self' plus every configured origin. + * + * The WebSocket lives on the same origins the operator already lists in + * CC_ALLOWED_ORIGINS, so naming them keeps the socket working while leaving + * CSP able to block a connection to anywhere else. Bare "ws: wss:" allowed + * any host at all. + */ + private String connectSrcOrigins() { + StringBuilder value = new StringBuilder("'self'"); + for (String origin : clipCascadeProperties.getAllowedOriginsArray()) { + if (origin == null || origin.isBlank()) { + continue; + } + value.append(' ').append(origin.trim()); + // The socket uses the ws(s) scheme against the same host. + if (origin.startsWith("https://")) { + value.append(' ').append("wss://").append(origin.substring("https://".length())); + } else if (origin.startsWith("http://")) { + value.append(' ').append("ws://").append(origin.substring("http://".length())); + } + } + return value.toString(); } // SessionRegistry bean to store session information @@ -75,6 +103,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .logout(logout -> logout .logoutUrl("/logout") // The URL to submit a logout request .logoutSuccessUrl("/login?logout")) // Where to go after successful logout + .headers(headers -> headers + .contentSecurityPolicy(csp -> csp.policyDirectives( + // Scripts are external-only (no unsafe-inline). Inline styles remain for legacy templates. + // connect-src names the configured origins rather than bare ws:/wss:, + // which would have allowed a socket to any host and removed CSP as an + // exfiltration backstop. + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src " + + connectSrcOrigins() + + "; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'")) + .referrerPolicy(referrer -> referrer.policy( + org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy.NO_REFERRER))) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.ALWAYS) // Always create a new session .maximumSessions(-1) // Allow unlimited sessions diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java index 582b21f8b..6ee3244ca 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java @@ -71,7 +71,7 @@ public void configureMessageBroker(@NonNull MessageBrokerRegistry config) { public void registerStompEndpoints(@NonNull StompEndpointRegistry registry) { // Clients will connect to this endpoint for WebSocket communication. registry.addEndpoint("/clipsocket") - .setAllowedOrigins(clipCascadeProperties.getAllowedOrigins()); + .setAllowedOrigins(clipCascadeProperties.getAllowedOriginsArray()); } // Scheduler for WebSocket heartbeats diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/IpResolverConstants.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/IpResolverConstants.java index bafb57654..d362b8e03 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/IpResolverConstants.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/IpResolverConstants.java @@ -2,20 +2,19 @@ public class IpResolverConstants { - // IP header candidates - public static final String[] IP_HEADER_CANDIDATES = { - "X-Forwarded-For", - "Proxy-Client-IP", - "WL-Proxy-Client-IP", - "HTTP_X_FORWARDED_FOR", - "HTTP_X_FORWARDED", - "HTTP_X_CLUSTER_CLIENT_IP", - "HTTP_CLIENT_IP", - "HTTP_FORWARDED_FOR", - "HTTP_FORWARDED", - "HTTP_VIA", - "REMOTE_ADDR" - }; + /** + * The single forwarding header consulted, and only when the request came + * from a trusted proxy. + * + * This used to be a list that also included Proxy-Client-IP, + * WL-Proxy-Client-IP, HTTP_VIA and friends. Trying them in turn is unsafe: + * a real proxy sets and overwrites X-Forwarded-For but does not touch the + * others, so a client could simply send one of them and choose the address + * that brute-force accounting is keyed on. Override only if your proxy uses + * a different header, and make sure that proxy overwrites it on every + * request. + */ + public static final String DEFAULT_FORWARDED_HEADER = "X-Forwarded-For"; // Unknown IP public static final String UNKNOWN = "unknown"; diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java index d517971fb..c62d08dd5 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java @@ -2,7 +2,7 @@ public class ServerConstants { // App version - public static final String APP_VERSION = "3.1.0"; + public static final String APP_VERSION = "3.2.0"; // Version URL public static final String VERSION_URL = "https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/version.json"; diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java index 80c54b458..e7fb7d2a1 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java @@ -51,11 +51,14 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; import org.springframework.web.bind.annotation.PutMapping; @Controller public class ClipCascadeController { + private static final int OUTBOUND_CONNECT_TIMEOUT_MS = 3000; + private static final int OUTBOUND_READ_TIMEOUT_MS = 5000; private final ClipCascadeProperties clipCascadeProperties; private final UserService userService; @@ -330,9 +333,13 @@ public ResponseEntity getLatestServerVersion( userPrincipal.isAdmin(), () -> ResponseEntityUtil.executeWithResponse( () -> { + if (!clipCascadeProperties.isUpdateCheckEnabled()) { + return Collections.singletonMap("server", ServerConstants.APP_VERSION); + } + try { // get latest version - RestTemplate restTemplate = new RestTemplate(); + RestTemplate restTemplate = restTemplateWithTimeouts(); String versionJson = restTemplate.getForObject( ServerConstants.VERSION_URL, String.class); @@ -347,6 +354,13 @@ public ResponseEntity getLatestServerVersion( "Forbidden"); } + private RestTemplate restTemplateWithTimeouts() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(OUTBOUND_CONNECT_TIMEOUT_MS); + requestFactory.setReadTimeout(OUTBOUND_READ_TIMEOUT_MS); + return new RestTemplate(requestFactory); + } + @GetMapping("/admin/websocket-stats") public ResponseEntity getWebSocketStats( @AuthenticationPrincipal UserPrincipal userPrincipal) { @@ -542,9 +556,11 @@ public ResponseEntity updatePassword( @RequestBody Map payload) { return ResponseEntityUtil.buildResponse( - facadeUserService.updatePassword( + facadeUserService.updateOwnPassword( userPrincipal.getUsername(), - payload.get("newPassword")) != null, + payload.get("currentPassword"), + payload.get("newPassword"), + sessionService) != null, "Password updated successfully", "Invalid user or password"); } @@ -560,7 +576,8 @@ public ResponseEntity updateUserPassword( () -> ResponseEntityUtil.buildResponse( facadeUserService.updatePassword( payload.get("username"), - payload.get("newPassword")) != null, + payload.get("newPassword"), + sessionService) != null, "Password updated successfully", "Invalid user or password"), "Forbidden"); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/DonationService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/DonationService.java index ebb7bc6a4..1b16f0365 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/DonationService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/DonationService.java @@ -3,6 +3,7 @@ import java.util.Map; import org.slf4j.LoggerFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @@ -14,6 +15,9 @@ @Service public class DonationService { + private static final int OUTBOUND_CONNECT_TIMEOUT_MS = 3000; + private static final int OUTBOUND_READ_TIMEOUT_MS = 5000; + private final ClipCascadeProperties clipCascadeProperties; private final ObjectMapper objectMapper; private final Logger logger; @@ -33,7 +37,7 @@ public void initializeDonationUrl() { return; try { - String response = new RestTemplate() + String response = restTemplateWithTimeouts() .getForObject(ServerConstants.METADATA_URL, String.class); donationUrl = (String) objectMapper.readValue(response, Map.class).get("funding"); @@ -46,4 +50,11 @@ public void initializeDonationUrl() { public String getDonationUrl() { return donationUrl; } + + private RestTemplate restTemplateWithTimeouts() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(OUTBOUND_CONNECT_TIMEOUT_MS); + requestFactory.setReadTimeout(OUTBOUND_READ_TIMEOUT_MS); + return new RestTemplate(requestFactory); + } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java index b6984355e..821d966c6 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java @@ -3,9 +3,11 @@ import java.util.Set; import java.util.stream.Collectors; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import com.acme.clipcascade.config.ClipCascadeProperties; +import com.acme.clipcascade.config.P2PWebSocketHandler; import com.acme.clipcascade.constants.RoleConstants; import com.acme.clipcascade.model.IpAttemptDetails; import com.acme.clipcascade.model.UserInfo; @@ -20,26 +22,42 @@ public class FacadeUserService { private final UserService userService; private final UserInfoService userInfoService; private final ClipCascadeProperties clipCascadeProperties; + private final P2PWebSocketHandler p2pWebSocketHandler; public FacadeUserService( UserService userService, UserInfoService userInfoService, - ClipCascadeProperties clipCascadeProperties) { + ClipCascadeProperties clipCascadeProperties, + @Nullable P2PWebSocketHandler p2pWebSocketHandler) { this.userService = userService; this.userInfoService = userInfoService; this.clipCascadeProperties = clipCascadeProperties; + this.p2pWebSocketHandler = p2pWebSocketHandler; } public void insertDefaultAdminUserIfEmpty() { if (userService.isTableEmpty()) { + if (!clipCascadeProperties.isInitialAdminPasswordConfigured()) { + throw new IllegalStateException( + "Empty user database. Set CC_INITIAL_ADMIN_PASSWORD before first startup."); + } + if (!UserValidator.isValidPassword(clipCascadeProperties.getInitialAdminPassword())) { + throw new IllegalStateException("CC_INITIAL_ADMIN_PASSWORD is too weak."); + } + + String initialAdminUsername = clipCascadeProperties.getInitialAdminUsername(); + if (!UserValidator.isValidUsername(initialAdminUsername)) { + throw new IllegalStateException("CC_INITIAL_ADMIN_USERNAME is invalid."); + } + userService.doubleHashAndCreateUser( - "admin", - "admin123", + initialAdminUsername, + clipCascadeProperties.getInitialAdminPassword(), RoleConstants.ADMIN, true); - userInfoService.registerNewUser("admin"); + userInfoService.registerNewUser(initialAdminUsername); } } @@ -76,7 +94,7 @@ public Users updateUsername( return null; } - sessionService.logoutAllSessions(oldUsername); + revokeSessions(oldUsername, sessionService); UserInfo userInfo = userInfoService.markUserForDeletion(oldUsername); if (userInfo == null) { @@ -96,14 +114,14 @@ public boolean deleteUser(String username, SessionService sessionService) { return false; } - sessionService.logoutAllSessions(username); + revokeSessions(username, sessionService); userInfoService.markUserForDeletion(username); return userService.deleteUser(username); } - public Users updatePassword(String username, String newPassword) { + public Users updatePassword(String username, String newPassword, SessionService sessionService) { if (!UserValidator.isValidUsername(username) || !UserValidator.isValidPassword(newPassword)) { @@ -112,7 +130,25 @@ public Users updatePassword(String username, String newPassword) { userInfoService.setPasswordChangeTime(username, TimeUtility.getCurrentTimeInSeconds()); - return userService.updatePassword(username, newPassword); + Users updatedUser = userService.updatePassword(username, newPassword); + if (updatedUser != null) { + revokeSessions(username, sessionService); + } + + return updatedUser; + } + + public Users updateOwnPassword( + String username, + String currentPassword, + String newPassword, + SessionService sessionService) { + + if (!userService.passwordMatches(username, currentPassword)) { + return null; + } + + return updatePassword(username, newPassword, sessionService); } public Users updateUserStatus( @@ -126,7 +162,7 @@ public Users updateUserStatus( return null; } - sessionService.logoutAllSessions(username); + revokeSessions(username, sessionService); return userService.updateUserStatus(username, enable); } @@ -167,4 +203,11 @@ public void deleteInactiveUsers(SessionService sessionService, Set exclud deleteUser(inactiveUser, sessionService); } } + + private void revokeSessions(String username, SessionService sessionService) { + sessionService.logoutAllSessions(username); + if (p2pWebSocketHandler != null) { + p2pWebSocketHandler.closeSessionsForUser(username); + } + } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java index 3dbc2e0c5..62cc71568 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java @@ -56,6 +56,15 @@ public boolean userExists(String username) { return userRepo.findByUsernameIgnoreCase(username) != null; } + public boolean passwordMatches(String username, String password) { + if (!UserValidator.isValidUsername(username) || !UserValidator.isValidPassword(password)) { + return false; + } + + Users user = userRepo.findById(username).orElse(null); + return user != null && bCryptPasswordEncoder.matches(password, user.getPassword()); + } + public List getUsers(String role) { List users = userRepo.findByRoleOrderByUsernameAsc(role); users.forEach(user -> { diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/IpAddressResolver.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/IpAddressResolver.java index 30f65680a..f37ed63bb 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/IpAddressResolver.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/IpAddressResolver.java @@ -1,5 +1,8 @@ package com.acme.clipcascade.utils; +import java.math.BigInteger; +import java.net.InetAddress; + import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -18,16 +21,287 @@ public static String getUserIpAddress() { } HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); - for (String header : IpResolverConstants.IP_HEADER_CANDIDATES) { - String ipList = request.getHeader(header); - if (ipList != null - && !ipList.isEmpty() - && !IpResolverConstants.UNKNOWN.equalsIgnoreCase(ipList)) { + return resolve(request, System.getenv("CC_TRUSTED_PROXY_CIDRS"), forwardedHeader()); + } + + /** + * Pure resolution, with configuration passed in so it can be tested. + * + * The env is read once at the public entry point above; everything below + * this line is a function of its arguments. + */ + public static String resolve( + HttpServletRequest request, String trustedCidrs, String headerName) { + String remoteAddress = request.getRemoteAddr(); + + // Forwarded headers are only meaningful when the peer that set them is a + // proxy we trust. Otherwise any client could name its own IP and defeat + // the per-IP brute-force limits that consume this value. + if (!isTrustedProxy(remoteAddress, trustedCidrs)) { + return remoteAddress; + } - return ipList.split(",")[0].trim(); + String ipList = joinedForwardedHeader(request, headerName); + if (ipList != null && !ipList.isEmpty()) { + String candidate = rightmostUntrustedHop(ipList, trustedCidrs); + if (candidate != null) { + return candidate; } } - return request.getRemoteAddr(); + // Header absent, or every hop in it was itself a trusted proxy. Fall + // back to the socket peer rather than consulting another header: any + // other header is one the proxy does not overwrite, so it would hand + // the client control of this value again. + return remoteAddress; + } + + /** + * All values of the forwarding header, joined left-to-right. + * + * A client can send its own header line, and a proxy that appends rather + * than replaces produces two separate lines. getHeader() returns only the + * FIRST, which is the client's — so reading it alone would hand the client + * the value again, defeating the point of walking right-to-left. + */ + private static String joinedForwardedHeader(HttpServletRequest request, String headerName) { + java.util.Enumeration values = request.getHeaders(headerName); + if (values == null) { + return null; + } + StringBuilder joined = new StringBuilder(); + while (values.hasMoreElements()) { + String value = values.nextElement(); + if (value == null || value.isBlank()) { + continue; + } + if (joined.length() > 0) { + joined.append(','); + } + joined.append(value); + } + return joined.toString(); + } + + private static String forwardedHeader() { + String configured = System.getenv("CC_FORWARDED_HEADER"); + if (configured == null || configured.isBlank()) { + return IpResolverConstants.DEFAULT_FORWARDED_HEADER; + } + return configured.trim(); + } + + /** + * The right-most hop that is not itself a trusted proxy, canonicalised. + * + * Proxies append to X-Forwarded-For rather than replacing it (nginx's + * $proxy_add_x_forwarded_for, and every comparable default), so a client + * that sends "X-Forwarded-For: 9.9.9.9" produces "9.9.9.9, <real client>". + * Taking the left-most entry would return the attacker's own chosen value; + * everything to the right of the first untrusted hop was written by + * infrastructure we trust, so that hop is the real client. + * + * Fails closed: if the right-most hop is not a usable IP literal we return + * null (so the caller uses the socket peer) rather than walking further + * left. Skipping junk and continuing would step over infrastructure-written + * entries into client-written ones, which is how "8.8.8.8, 203.0.113.9:54321" + * ended up resolving to the client's own value. + */ + private static String rightmostUntrustedHop(String ipList, String trustedCidrs) { + String[] hops = ipList.split(","); + for (int i = hops.length - 1; i >= 0; i--) { + String hop = hops[i].trim(); + if (hop.isEmpty()) { + continue; + } + String canonical = canonicalIp(hop); + if (canonical == null) { + // Junk, a hostname, or "unknown": stop rather than skip. + return null; + } + if (!isTrustedProxy(canonical, trustedCidrs)) { + return canonical; + } + } + return null; + } + + /** + * Normalise an IP literal to one canonical string, or null if it is not one. + * + * Brute-force accounting keys on this value, so two spellings of the same + * host must not produce two buckets: "203.0.113.050" and "203.0.113.50", + * or "::1" and "[::1]", would otherwise be separate counters and an + * attacker could rotate spellings to stay under the limit. Also strips an + * optional :port, which some proxies append, and refuses hostnames so that + * an inbound header can never trigger a DNS lookup on the request path. + */ + static String canonicalIp(String value) { + if (value == null || value.isBlank()) { + return null; + } + String candidate = value.trim(); + if (IpResolverConstants.UNKNOWN.equalsIgnoreCase(candidate)) { + return null; + } + + // [::1]:8080 or [::1] + if (candidate.startsWith("[")) { + int close = candidate.indexOf(']'); + if (close < 0) { + return null; + } + candidate = candidate.substring(1, close); + } else { + // IPv4 with a port; a bare IPv6 has many colons, so only strip when + // there is exactly one. + int colon = candidate.indexOf(':'); + if (colon >= 0 && candidate.indexOf(':', colon + 1) < 0) { + candidate = candidate.substring(0, colon); + } + } + // Drop an IPv6 zone index (fe80::1%eth0) — it is host-local and not + // meaningful as an identity here. + int zone = candidate.indexOf('%'); + if (zone >= 0) { + candidate = candidate.substring(0, zone); + } + if (candidate.isEmpty() || !isIpLiteral(candidate)) { + return null; + } + + try { + // Safe: isIpLiteral has already excluded anything that would resolve. + return InetAddress.getByName(candidate).getHostAddress(); + } catch (Exception e) { + return null; + } + } + + /** + * True for a bare IPv4 or IPv6 literal (optionally bracketed). + * + * Deliberately strict: it gates every value that reaches InetAddress, so a + * hostname never gets that far. + */ + public static boolean isIpLiteral(String value) { + if (value == null || value.isBlank()) { + return false; + } + String candidate = value.trim(); + if (candidate.startsWith("[") && candidate.endsWith("]") && candidate.length() > 2) { + candidate = candidate.substring(1, candidate.length() - 1); + } + if (candidate.indexOf(':') >= 0) { + // At most one "::", and every group a valid hextet. A permissive + // character-class match accepts junk like ":::" — which still keys + // a distinct brute-force bucket, so it has to be refused. + if (candidate.indexOf("::") != candidate.lastIndexOf("::")) { + return false; + } + if (candidate.equals(":") || candidate.endsWith(":") && !candidate.endsWith("::")) { + return false; + } + String[] groups = candidate.split(":", -1); + if (groups.length > 8) { + return false; + } + int emptyGroups = 0; + boolean sawHextet = false; + for (int i = 0; i < groups.length; i++) { + String group = groups[i]; + if (group.isEmpty()) { + emptyGroups++; + continue; + } + // A trailing IPv4 form (::ffff:1.2.3.4) is legal in the last group. + if (i == groups.length - 1 && group.indexOf('.') >= 0) { + if (!isDottedQuad(group)) { + return false; + } + sawHextet = true; + continue; + } + if (!group.matches("[0-9A-Fa-f]{1,4}")) { + return false; + } + sawHextet = true; + } + // "::" produces two empties at most; ":::" produces more. + return sawHextet && emptyGroups <= 2; + } + + return isDottedQuad(candidate); + } + + /** + * Strict dotted-quad: no leading zeros. + * + * "203.0.113.050" and "203.0.113.50" are the same host, but Java reads the + * leading zero as decimal while other stacks read it as octal. Accepting + * both spellings would let one client occupy two brute-force buckets, so + * the ambiguous form is refused outright. + */ + private static boolean isDottedQuad(String candidate) { + String[] octets = candidate.split("\\.", -1); + if (octets.length != 4) { + return false; + } + for (String octet : octets) { + if (!octet.matches("(0|[1-9]\\d{0,2})")) { + return false; + } + if (Integer.parseInt(octet) > 255) { + return false; + } + } + return true; + } + + private static boolean isTrustedProxy(String remoteAddress, String trustedCidrs) { + if (trustedCidrs == null || trustedCidrs.isBlank()) { + return false; + } + + for (String cidr : trustedCidrs.split(",")) { + if (addressMatchesCidr(remoteAddress, cidr.trim())) { + return true; + } + } + return false; + } + + private static boolean addressMatchesCidr(String address, String cidr) { + if (address == null || address.isBlank() || cidr == null || cidr.isBlank()) { + return false; + } + // Guard InetAddress against anything that would trigger a DNS lookup. + if (!isIpLiteral(address)) { + return false; + } + + try { + if (!cidr.contains("/")) { + return InetAddress.getByName(address).equals(InetAddress.getByName(cidr)); + } + + String[] parts = cidr.split("/", 2); + InetAddress ip = InetAddress.getByName(address); + InetAddress network = InetAddress.getByName(parts[0]); + int prefixLength = Integer.parseInt(parts[1]); + + byte[] ipBytes = ip.getAddress(); + byte[] networkBytes = network.getAddress(); + if (ipBytes.length != networkBytes.length || prefixLength < 0 || prefixLength > ipBytes.length * 8) { + return false; + } + + BigInteger ipValue = new BigInteger(1, ipBytes); + BigInteger networkValue = new BigInteger(1, networkBytes); + int shift = ipBytes.length * 8 - prefixLength; + return ipValue.shiftRight(shift).equals(networkValue.shiftRight(shift)); + } catch (Exception e) { + return false; + } } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/ResponseEntityUtil.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/ResponseEntityUtil.java index 4ed8a960c..f3514e2d0 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/ResponseEntityUtil.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/ResponseEntityUtil.java @@ -2,6 +2,7 @@ import java.util.function.Supplier; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; public class ResponseEntityUtil { @@ -14,7 +15,7 @@ public static ResponseEntity executeWithResponse(Supplier action) { try { return ResponseEntity.ok(action.get()); } catch (Exception e) { - return ResponseEntity.badRequest().body((T) e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body((T) "Internal server error"); } } @@ -25,6 +26,13 @@ public static ResponseEntity conditionalExecuteOrError(boolean condition, return condition ? successAction.get() - : ResponseEntity.badRequest().body((T) errorMessage); + : ResponseEntity.status(resolveErrorStatus(errorMessage)).body((T) errorMessage); + } + + private static HttpStatus resolveErrorStatus(String errorMessage) { + if ("Forbidden".equalsIgnoreCase(errorMessage)) { + return HttpStatus.FORBIDDEN; + } + return HttpStatus.BAD_REQUEST; } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java index 303fbb886..9b7bc22ca 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java @@ -4,6 +4,10 @@ import com.acme.clipcascade.model.Users; public class UserValidator { + private static final int MIN_RAW_PASSWORD_LENGTH = 12; + private static final int SHA3_512_HEX_LENGTH = 128; + private static final String SHA3_512_HEX_PATTERN = "^[0-9a-fA-F]{128}$"; + public static boolean isValid(Users user) { return user != null && user.getUsername() != null && !user.getUsername().isBlank() @@ -19,7 +23,12 @@ public static boolean isValidUsername(String username) { } public static boolean isValidPassword(String password) { - return password != null && !password.isEmpty(); + if (password == null || password.isBlank()) { + return false; + } + + return password.length() >= MIN_RAW_PASSWORD_LENGTH + || (password.length() == SHA3_512_HEX_LENGTH && password.matches(SHA3_512_HEX_PATTERN)); } public static boolean isValidRole(String role) { diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties index eb3fb7703..dd4b493b5 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties @@ -6,7 +6,7 @@ spring.application.name=ClipCascade spring.datasource.url=${CC_SERVER_DB_URL:jdbc:h2:file:./database/clipcascade;CIPHER=AES;MODE=PostgreSQL} spring.datasource.driverClassName=${CC_SERVER_DB_DRIVER:org.h2.Driver} spring.datasource.username=${CC_SERVER_DB_USERNAME:clipcascade} -spring.datasource.password=${CC_SERVER_DB_PASSWORD:QjuGlhE3uwylBBANMkX1 o2MdEoFgbU5XkFvTftky} +spring.datasource.password=${CC_SERVER_DB_PASSWORD:} spring.sql.init.mode=always @@ -21,7 +21,7 @@ spring.jpa.properties.hibernate.dialect=${CC_SERVER_DB_HIBERNATE_DIALECT:org.hib # Server Configuration # --------------------------------------- server.port=${CC_PORT:8080} -server.servlet.session.timeout=${CC_SESSION_TIMEOUT:525960m} +server.servlet.session.timeout=${CC_SESSION_TIMEOUT:1440m} # --------------------------------------- diff --git a/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/ClipCascadeApplicationTests.java b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/ClipCascadeApplicationTests.java index 5a58ed8f8..82a0fc641 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/ClipCascadeApplicationTests.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/ClipCascadeApplicationTests.java @@ -3,8 +3,19 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import com.acme.clipcascade.ClipCascadeApplication; + // @SpringBootTest -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = ClipCascadeApplication.class, + properties = { + "CC_INITIAL_ADMIN_PASSWORD=test-admin-password", + "CC_SERVER_DB_URL=jdbc:h2:mem:clipcascade_test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.url=jdbc:h2:mem:clipcascade_test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.password=", + "logging.file.name=${java.io.tmpdir}/clipcascade-test.log" + }) class ClipCascadeApplicationTests { @Test diff --git a/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/config/ClipCascadePropertiesTest.java b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/config/ClipCascadePropertiesTest.java new file mode 100644 index 000000000..646ad45ce --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/config/ClipCascadePropertiesTest.java @@ -0,0 +1,69 @@ +package com.acme.ClipCascade.config; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import com.acme.clipcascade.config.ClipCascadeProperties; + +class ClipCascadePropertiesTest { + + private static ClipCascadeProperties withOrigins(String origins) { + ClipCascadeProperties properties = new ClipCascadeProperties(); + ReflectionTestUtils.setField(properties, "allowedOrigins", origins); + // Keep the unrelated H2 guard quiet; this test is about origins. + ReflectionTestUtils.setField(properties, "serverDbUrl", "jdbc:postgresql://db/clipcascade"); + return properties; + } + + private static void validate(ClipCascadeProperties properties) { + ReflectionTestUtils.invokeMethod(properties, "validateRequiredSecrets"); + } + + @Test + void wildcardOriginFailsStartup() { + // A wildcard is applied verbatim to both WebSocket endpoints, so any + // site could open an authenticated socket with the visitor's cookie and + // read their clipboard. It has to fail loudly, not be silently narrowed. + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> validate(withOrigins("*"))); + assertTrue(error.getMessage().contains("CC_ALLOWED_ORIGINS")); + } + + @Test + void wildcardMixedWithExactOriginsAlsoFailsStartup() { + assertThrows( + IllegalStateException.class, + () -> validate(withOrigins("http://100.64.0.1:8080, *"))); + } + + @Test + void exactOriginsAreAccepted() { + ClipCascadeProperties properties = + withOrigins("http://100.64.0.1:8080, https://box.example.ts.net"); + assertDoesNotThrow(() -> validate(properties)); + assertArrayEquals( + new String[] {"http://100.64.0.1:8080", "https://box.example.ts.net"}, + properties.getAllowedOriginsArray()); + } + + @Test + void wildcardIsDroppedFromTheOriginsArray() { + // Defence in depth for any caller that skips validateRequiredSecrets. + assertArrayEquals( + new String[] {"http://100.64.0.1:8080"}, + withOrigins("http://100.64.0.1:8080,*").getAllowedOriginsArray()); + } + + @Test + void blankOriginsFallBackToLocalhost() { + assertArrayEquals( + new String[] {"http://localhost:8080"}, + withOrigins("").getAllowedOriginsArray()); + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/controller/ClipCascadeRelayTest.java b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/controller/ClipCascadeRelayTest.java new file mode 100644 index 000000000..94b51d201 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/controller/ClipCascadeRelayTest.java @@ -0,0 +1,137 @@ +package com.acme.ClipCascade.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +import com.acme.clipcascade.config.ClipCascadeProperties; +import com.acme.clipcascade.controller.ClipCascadeController; +import com.acme.clipcascade.model.ClipboardData; +import com.acme.clipcascade.model.UserPrincipal; +import com.acme.clipcascade.model.Users; +import com.acme.clipcascade.service.BruteForceProtectionService; +import com.acme.clipcascade.service.CaptchaService; +import com.acme.clipcascade.service.DonationService; +import com.acme.clipcascade.service.FacadeUserService; +import com.acme.clipcascade.service.SessionService; +import com.acme.clipcascade.service.UserInfoService; +import com.acme.clipcascade.service.UserService; +import com.acme.clipcascade.service.WebSocketStatsService; + +/** + * The clipboard endpoint is an opaque relay. + * + * It rebuilds the outgoing message from three getters on ClipboardData, so any + * other top-level field a client sends is dropped in transit. Clients therefore + * nest their protocol envelope inside `payload`, and this test pins the two + * properties that makes safe: `payload` is relayed byte-identical, and nothing + * else about it is interpreted. + * + * Protocol v2 originally shipped four extra top-level fields and was silently + * stripped here, which broke sync in the default configuration. No test crossed + * the server, so nothing caught it. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ClipCascadeRelayTest { + + @Mock private ClipCascadeProperties clipCascadeProperties; + @Mock private FacadeUserService facadeUserService; + @Mock private UserService userService; + @Mock private SimpMessagingTemplate simpMessagingTemplate; + @Mock private CaptchaService captchaService; + @Mock private UserInfoService userInfoService; + @Mock private SessionService sessionService; + @Mock private BruteForceProtectionService bruteForceProtectionService; + @Mock private WebSocketStatsService webSocketStatsService; + @Mock private DonationService donationService; + + private ClipCascadeController controller() { + return new ClipCascadeController( + clipCascadeProperties, + facadeUserService, + userService, + simpMessagingTemplate, + captchaService, + userInfoService, + sessionService, + bruteForceProtectionService, + webSocketStatsService, + donationService); + } + + private UsernamePasswordAuthenticationToken principal() { + Users user = new Users(); + user.setUsername("alice"); + user.setPassword("irrelevant"); + user.setRole("ROLE_USER"); + UserPrincipal userPrincipal = new UserPrincipal(user, bruteForceProtectionService); + return new UsernamePasswordAuthenticationToken( + userPrincipal, null, Collections.emptyList()); + } + + private ClipboardData relay(ClipboardData in) { + when(clipCascadeProperties.isP2pEnabled()).thenReturn(false); + controller().sendPrivateMessage(principal(), in); + + ArgumentCaptor sent = ArgumentCaptor.forClass(ClipboardData.class); + verify(simpMessagingTemplate) + .convertAndSendToUser(eq("alice"), eq("/queue/cliptext"), sent.capture()); + return sent.getValue(); + } + + @Test + void payloadIsRelayedByteIdentical() { + // Clients nest their protocol envelope in here. If anyone ever adds + // trimming, re-encoding, sanitising or truncation to the relay, this + // breaks loudly instead of silently corrupting every message. + String envelope = + "{\"v\":2,\"type\":\"text\",\"senderDeviceId\":\"device-A\",\"counter\":1," + + "\"ts\":1700000000000,\"payload\":\"{\\\"nonce\\\":\\\"abc\\\"}\"}"; + + ClipboardData out = relay(new ClipboardData(envelope, "text", null)); + + assertEquals(envelope, out.getPayload()); + } + + @Test + void typeDefaultsToTextWhenAbsent() { + assertEquals("text", relay(new ClipboardData("x", null, null)).getType()); + } + + @Test + void typeIsOtherwisePassedThrough() { + assertEquals("image", relay(new ClipboardData("x", "image", null)).getType()); + } + + @Test + void metadataIsRelayedUntouched() { + java.util.Map metadata = java.util.Map.of("id", "abc", "index", 0); + assertSame(metadata, relay(new ClipboardData("x", "text", metadata)).getMetadata()); + } + + @Test + void nothingIsRelayedWhenP2pIsEnabled() { + when(clipCascadeProperties.isP2pEnabled()).thenReturn(true); + controller().sendPrivateMessage(principal(), new ClipboardData("x", "text", null)); + verify(simpMessagingTemplate, never()) + .convertAndSendToUser(anyString(), anyString(), any(Object.class)); + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/service/FacadeUserServiceTest.java b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/service/FacadeUserServiceTest.java new file mode 100644 index 000000000..ffbb7f311 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/service/FacadeUserServiceTest.java @@ -0,0 +1,91 @@ +package com.acme.ClipCascade.service; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.acme.clipcascade.config.ClipCascadeProperties; +import com.acme.clipcascade.config.P2PWebSocketHandler; +import com.acme.clipcascade.constants.RoleConstants; +import com.acme.clipcascade.service.FacadeUserService; +import com.acme.clipcascade.service.SessionService; +import com.acme.clipcascade.service.UserInfoService; +import com.acme.clipcascade.service.UserService; + +@ExtendWith(MockitoExtension.class) +class FacadeUserServiceTest { + + @Mock + private UserService userService; + + @Mock + private UserInfoService userInfoService; + + @Mock + private ClipCascadeProperties clipCascadeProperties; + + @Mock + private P2PWebSocketHandler p2pWebSocketHandler; + + @Mock + private SessionService sessionService; + + @InjectMocks + private FacadeUserService facadeUserService; + + @Test + void emptyDatabaseRequiresInitialAdminPassword() { + when(userService.isTableEmpty()).thenReturn(true); + when(clipCascadeProperties.isInitialAdminPasswordConfigured()).thenReturn(false); + + assertThrows(IllegalStateException.class, facadeUserService::insertDefaultAdminUserIfEmpty); + + verify(userService, never()).doubleHashAndCreateUser( + "admin", + "admin123", + RoleConstants.ADMIN, + true); + } + + @Test + void emptyDatabaseCreatesConfiguredInitialAdmin() { + when(userService.isTableEmpty()).thenReturn(true); + when(clipCascadeProperties.isInitialAdminPasswordConfigured()).thenReturn(true); + when(clipCascadeProperties.getInitialAdminUsername()).thenReturn("owner"); + when(clipCascadeProperties.getInitialAdminPassword()).thenReturn("strong-password"); + + facadeUserService.insertDefaultAdminUserIfEmpty(); + + verify(userService).doubleHashAndCreateUser( + "owner", + "strong-password", + RoleConstants.ADMIN, + true); + verify(userInfoService).registerNewUser("owner"); + } + + @Test + void updateOwnPasswordRejectsWrongCurrentPassword() { + when(userService.passwordMatches("alice", "wrong-current")).thenReturn(false); + + assertNull( + facadeUserService.updateOwnPassword( + "alice", + "wrong-current", + "new-password-long-enough", + sessionService)); + + verify(userService, never()).updatePassword(anyString(), anyString()); + verify(userInfoService, never()).setPasswordChangeTime(anyString(), anyLong()); + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/utils/IpAddressResolverTest.java b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/utils/IpAddressResolverTest.java new file mode 100644 index 000000000..de48b7d12 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/test/java/com/acme/ClipCascade/utils/IpAddressResolverTest.java @@ -0,0 +1,185 @@ +package com.acme.ClipCascade.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import com.acme.clipcascade.utils.IpAddressResolver; + +/** + * The resolved address keys per-IP brute-force accounting, so a client must + * never be able to choose it. + * + * Note these run without CC_TRUSTED_PROXY_CIDRS set, which is the default and + * the shipped posture: forwarded headers are ignored outright. + */ +class IpAddressResolverTest { + + private static void request(String remoteAddr, String... headerPairs) { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRemoteAddr(remoteAddr); + for (int i = 0; i + 1 < headerPairs.length; i += 2) { + req.addHeader(headerPairs[i], headerPairs[i + 1]); + } + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(req)); + } + + @AfterEach + void clear() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void untrustedPeerCannotSpoofItsAddress() { + request("203.0.113.9", "X-Forwarded-For", "1.2.3.4"); + assertEquals("203.0.113.9", IpAddressResolver.getUserIpAddress()); + } + + @Test + void secondaryHeadersAreNeverConsulted() { + // A proxy overwrites X-Forwarded-For but leaves these alone, so honouring + // them would hand the client back control of its recorded address. + request("203.0.113.9", + "Proxy-Client-IP", "9.9.9.9", + "WL-Proxy-Client-IP", "9.9.9.9", + "HTTP_CLIENT_IP", "9.9.9.9", + "HTTP_VIA", "1.1 vegur"); + assertEquals("203.0.113.9", IpAddressResolver.getUserIpAddress()); + } + + @Test + void hostnamesInTheHeaderAreIgnored() { + // Must not reach InetAddress, or an inbound header becomes a DNS lookup. + request("203.0.113.9", "X-Forwarded-For", "example.com"); + assertEquals("203.0.113.9", IpAddressResolver.getUserIpAddress()); + } + + @Test + void missingRequestContextIsHandled() { + RequestContextHolder.resetRequestAttributes(); + assertEquals("0.0.0.0", IpAddressResolver.getUserIpAddress()); + } + + @Test + void ipLiteralDetectionRejectsHostnamesAndJunk() { + assertTrue(IpAddressResolver.isIpLiteral("10.0.0.5")); + assertTrue(IpAddressResolver.isIpLiteral("203.0.113.9")); + assertTrue(IpAddressResolver.isIpLiteral("::1")); + assertTrue(IpAddressResolver.isIpLiteral("[fd7a:115c:a1e0::1]")); + + assertFalse(IpAddressResolver.isIpLiteral("example.com")); + assertFalse(IpAddressResolver.isIpLiteral("1.1 vegur")); + assertFalse(IpAddressResolver.isIpLiteral("999.1.1.1")); + assertFalse(IpAddressResolver.isIpLiteral("unknown")); + assertFalse(IpAddressResolver.isIpLiteral("")); + assertFalse(IpAddressResolver.isIpLiteral(null)); + + // Junk IPv6 previously passed a permissive character-class match and + // then keyed its own brute-force bucket. + assertFalse(IpAddressResolver.isIpLiteral(":")); + assertFalse(IpAddressResolver.isIpLiteral(":::")); + assertFalse(IpAddressResolver.isIpLiteral("::::")); + assertFalse(IpAddressResolver.isIpLiteral("1::2::3")); + + // Leading zeros are ambiguous (decimal here, octal elsewhere) and would + // let one host occupy two buckets. + assertFalse(IpAddressResolver.isIpLiteral("203.0.113.050")); + assertFalse(IpAddressResolver.isIpLiteral("010.0.0.1")); + } + + // --- The trusted-proxy path. Previously untested: every case above runs + // --- with CC_TRUSTED_PROXY_CIDRS unset, i.e. the path where the header is + // --- ignored outright, so none of them could catch the bugs below. + + private static MockHttpServletRequest req(String remoteAddr, String... headerPairs) { + MockHttpServletRequest r = new MockHttpServletRequest(); + r.setRemoteAddr(remoteAddr); + for (int i = 0; i + 1 < headerPairs.length; i += 2) { + r.addHeader(headerPairs[i], headerPairs[i + 1]); + } + return r; + } + + private static String resolveTrusted(MockHttpServletRequest r) { + return IpAddressResolver.resolve(r, "10.0.0.0/8", "X-Forwarded-For"); + } + + @Test + void trustedProxyChainYieldsTheRealClient() { + assertEquals("203.0.113.9", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "203.0.113.9"))); + // A client-supplied prefix must not win: proxies append, so the client's + // own value ends up left-most. + assertEquals("203.0.113.9", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "1.2.3.4, 203.0.113.9"))); + } + + @Test + void duplicateHeaderLinesCannotBeUsedToWin() { + // A client sends its own line; the proxy appends a second. getHeader() + // returns only the FIRST — the client's — so all values must be joined. + MockHttpServletRequest r = req("10.0.0.1"); + r.addHeader("X-Forwarded-For", "1.2.3.4"); + r.addHeader("X-Forwarded-For", "203.0.113.9"); + assertEquals("203.0.113.9", resolveTrusted(r)); + } + + @Test + void rightmostHopWithAPortResolvesToItsAddress() { + // Some proxies append "ip:port". The address is legitimate, so use it — + // the point is that the client-supplied left-most "8.8.8.8" must never + // be what comes back. + assertEquals("203.0.113.9", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "8.8.8.8, 203.0.113.9:54321"))); + } + + @Test + void unusableRightmostHopFailsClosedToTheSocketPeer() { + // Skipping junk and walking further left would step into client-written + // entries, so stop instead. "8.8.8.8" is the client's and must not win. + assertEquals("10.0.0.1", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "8.8.8.8, :::"))); + assertEquals("10.0.0.1", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "8.8.8.8, example.com"))); + assertEquals("10.0.0.1", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "8.8.8.8, unknown"))); + assertEquals("10.0.0.1", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "8.8.8.8, 203.0.113.050"))); + } + + @Test + void spellingVariantsCollapseToOneLockoutKey() { + // Otherwise an attacker rotates spellings to spread failures across + // buckets and never trips the limit. + assertEquals("203.0.113.9", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "203.0.113.9"))); + assertEquals("10.0.0.1", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "203.0.113.050"))); + assertEquals("0:0:0:0:0:0:0:1", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "::1"))); + assertEquals("0:0:0:0:0:0:0:1", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "[::1]"))); + } + + @Test + void allHopsTrustedFallsBackToTheSocketPeer() { + assertEquals("10.0.0.1", + resolveTrusted(req("10.0.0.1", "X-Forwarded-For", "10.0.0.7, 10.0.0.8"))); + } + + @Test + void headerIsIgnoredEntirelyWhenNoProxyIsTrusted() { + assertEquals("10.0.0.1", + IpAddressResolver.resolve( + req("10.0.0.1", "X-Forwarded-For", "1.2.3.4"), null, "X-Forwarded-For")); + assertEquals("10.0.0.1", + IpAddressResolver.resolve( + req("10.0.0.1", "X-Forwarded-For", "1.2.3.4"), " ", "X-Forwarded-For")); + } +} From f9749309d85afa685ce6da6657d71f4f508af784 Mon Sep 17 00:00:00 2001 From: Yevhen Salitrynskyi Date: Sun, 19 Jul 2026 10:04:17 -0400 Subject: [PATCH 2/6] feat(web): CSP without inline scripts; modal reauth Content-Security-Policy is only meaningful if the pages can live without script-src 'unsafe-inline', so the inline handlers and script bodies moved out to login.js, signup.js, advance.js and main.js. No - - + + + diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/login.html b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/login.html index 6e3d11fce..166c97be4 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/login.html +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/login.html @@ -19,7 +19,7 @@ name="keywords" content="ClipCascade, clipboard sync, secure clipboard, multi-device clipboard, end-to-end encryption, Windows clipboard sync, Mac clipboard sync, Linux clipboard sync, Android clipboard sync, Docker clipboard app" /> - +