Initial commit

This commit is contained in:
Pit Friedrich
2026-10-11 17:45:06 +02:00
commit d9a9647742
43 changed files with 3723 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.leakfinder</groupId>
<artifactId>vaadin-leakfinder-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>demo-app</artifactId>
<name>Vaadin Leak Finder Demo App</name>
<description>Vaadin 25 app with deliberate inter-session leaks</description>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-dev</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<!-- Runnable fat jar: mvn -Pproduction package; java -jar target/demo-app-*.jar -->
<profile>
<id>production</id>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-dev</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>prepare-frontend</goal>
<goal>build-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
html, body, #outlet {
height: 100%;
width: 100%;
margin: 0;
}
/*
Fix app height for iOS home screen apps:
- 100% doesn't cover the entire screen.
- 100vh would work on iOS 18 but not on iOS 17 (too tall in Safari, correct in standalone mode).
- 100lvh is too tall in Safari. 100svh is too short in standalone apps.
- 100dvh doesn't seem to work on first render when launched from home screen (initially like 100svh, then changes to 100lvh when you try to scroll the view).
- So, we use 100% for the regular case (Safari and other browsers), and 100lvh for iOS home screen apps. Note, that the `display-mode: standalone` media query only works if the webmanifest defines `"display": "standalone"`.
*/
@supports (-webkit-touch-callout: none) {
@media (display-mode: standalone) {
html {
height: 100lvh;
}
}
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
</body>
</html>
@@ -0,0 +1,12 @@
package com.example.leakdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
/** Classic Vaadin broadcaster. Fine by itself, leaks when listeners are never removed. */
public final class Broadcaster {
private static final List<Consumer<String>> LISTENERS = new CopyOnWriteArrayList<>();
private Broadcaster() {
}
public static Runnable register(Consumer<String> listener) {
LISTENERS.add(listener);
return () -> LISTENERS.remove(listener);
}
public static void broadcast(String message) {
LISTENERS.forEach(l -> l.accept(message));
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
/** LEAK: registers with the static broadcaster on attach but never unregisters on detach. */
@Route("broadcaster-leak")
public class BroadcasterView extends VerticalLayout {
public BroadcasterView() {
add(new H2("Broadcaster leak"));
}
@Override
protected void onAttach(AttachEvent attachEvent) {
UI ui = attachEvent.getUI();
Broadcaster.register(message -> ui.access(() -> add(new Span(message))));
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
/** Control view: per-user state only, must produce no findings. */
@Route("")
public class CleanView extends VerticalLayout {
private final Paragraph counter = new Paragraph("0");
public CleanView() {
add(new H1("Vaadin leak finder demo"), counter);
add(new Anchor("static-leak", "Static list of views"));
add(new Anchor("singleton-leak", "Singleton bean holding a grid"));
add(new Anchor("broadcaster-leak", "Broadcaster without unregister"));
add(new Anchor("shared-component", "Component shared via static field"));
add(new Anchor("snoop", "View peeking into another user's view"));
}
}
@@ -0,0 +1,19 @@
package com.example.leakdemo;
import com.vaadin.flow.component.grid.Grid;
import org.springframework.stereotype.Service;
/** LEAK: application wide singleton that caches a UI component. */
@Service
public class GridCache {
private Grid<String> lastGrid;
public void remember(Grid<String> grid) {
this.lastGrid = grid;
}
public Grid<String> lastGrid() {
return lastGrid;
}
}
@@ -0,0 +1,27 @@
package com.example.leakdemo;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** Helper endpoints for the end-to-end test. */
@RestController
public class SessionController {
@GetMapping("/api/logout")
public String logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
return "invalidated";
}
return "no session";
}
@GetMapping("/api/broadcast")
public String broadcast() {
Broadcaster.broadcast("hello");
return "sent";
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
/**
* LEAK: one component instance for all users. Flow throws "Can't move a node from one state tree to
* another" when the second user opens the view; calling {@code removeFromTree()} silences that, and
* now every visitor steals the banner from the previous visitor's UI, mutating that user's UI from
* the wrong request thread.
*/
@Route("shared-component")
public class SharedComponentView extends VerticalLayout {
private static final Span BANNER = new Span("Banner of the day");
public SharedComponentView() {
BANNER.getElement().removeFromTree();
add(new H2("Shared component"), BANNER);
}
}
@@ -0,0 +1,20 @@
package com.example.leakdemo;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import java.util.List;
@Route("singleton-leak")
public class SingletonLeakView extends VerticalLayout {
public SingletonLeakView(GridCache cache) {
Grid<String> grid = new Grid<>();
grid.addColumn(s -> s).setHeader("Item");
grid.setItems(List.of("a", "b", "c"));
cache.remember(grid);
add(new H2("Singleton leak"), grid);
}
}
@@ -0,0 +1,24 @@
package com.example.leakdemo;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
/** LEAK: the view of one user keeps a reference to a view of a different user. */
@Route("snoop")
public class SnoopView extends VerticalLayout {
private Component peeked;
public SnoopView() {
add(new H2("Snoop"));
synchronized (StaticLeakView.RECENT_VIEWS) {
if (!StaticLeakView.RECENT_VIEWS.isEmpty()) {
peeked = StaticLeakView.RECENT_VIEWS.get(0);
}
}
add(new Paragraph(peeked == null ? "nothing to peek at" : "peeking at " + peeked.getClass().getSimpleName()));
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import java.util.ArrayList;
import java.util.List;
/** LEAK: a static "recently opened views" list keeps every user's view (and its UI) alive forever. */
@Route("static-leak")
public class StaticLeakView extends VerticalLayout {
static final List<Component> RECENT_VIEWS = new ArrayList<>();
public StaticLeakView() {
add(new H2("Static leak"));
synchronized (RECENT_VIEWS) {
RECENT_VIEWS.add(this);
}
}
}
@@ -0,0 +1,4 @@
server.port=${PORT:8080}
vaadin.launch-browser=false
spring.main.banner-mode=off
logging.level.org.atmosphere=warn