migrate filtering expressions to safer CEL and add a query builder

This commit is contained in:
Athou
2026-02-15 10:20:50 +01:00
parent 08bfcded7f
commit d444a7080d
46 changed files with 862 additions and 590 deletions

View File

@@ -4,6 +4,7 @@ import java.time.Duration;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -48,49 +49,219 @@ class FeedEntryFilteringServiceTest {
}
@Test
void simpleExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("author.toString() eq 'athou'", entry));
void simpleEqualsExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("author == \"Athou\"", entry));
}
@Test
void newIsDisabled() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("null eq new ('java.lang.String', 'athou')", entry));
void simpleNotEqualsExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("author != \"other\"", entry));
}
@Test
void getClassMethodIsDisabled() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("null eq ''.getClass()", entry));
void containsExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("author.contains(\"Athou\")", entry));
}
@Test
void dotClassIsDisabled() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("null eq ''.class", entry));
void titleContainsExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("title.contains(\"Merge\")", entry));
}
@Test
void cannotLoopForever() {
Mockito.when(config.feedRefresh().filteringExpressionEvaluationTimeout()).thenReturn(Duration.ofMillis(200));
service = new FeedEntryFilteringService(config);
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("while(true) {}", entry));
void urlContainsExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("url.contains(\"github\")", entry));
}
@Test
void handlesNullCorrectly() {
entry.setUrl(null);
entry.setContent(new FeedEntryContent());
Assertions.assertDoesNotThrow(() -> service.filterMatchesEntry("author eq 'athou'", entry));
void andExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("author == \"Athou\" && url.contains(\"github\")", entry));
}
@Test
void incorrectScriptThrowsException() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("aa eqz bb", entry));
void orExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("author == \"other\" || url.contains(\"github\")", entry));
}
@Test
void incorrectReturnTypeThrowsException() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("1", entry));
void notExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("!(author == \"other\")", entry));
}
@Test
void incorrectExpressionThrowsException() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("not valid cel", entry));
}
@Test
void falseValueReturnsFalse() throws FeedEntryFilterException {
Assertions.assertFalse(service.filterMatchesEntry("false", entry));
}
@Test
void trueValueReturnsTrue() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("true", entry));
}
@Test
void startsWithExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("title.startsWith(\"Merge\")", entry));
}
@Test
void endsWithExpression() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("url.endsWith(\"commafeed\")", entry));
}
@Test
void categoriesContainsExpression() throws FeedEntryFilterException {
FeedEntryContent content = entry.getContent();
content.setCategories("tech, programming, java");
entry.setContent(content);
Assertions.assertTrue(service.filterMatchesEntry("categories.contains(\"programming\")", entry));
}
@Test
void caseInsensitiveAuthorMatchUsingLowerVariable() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("authorLower == \"athou\"", entry));
}
@Test
void caseInsensitiveTitleMatchUsingLowerVariable() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("titleLower.contains(\"merge\")", entry));
}
@Test
void caseInsensitiveUrlMatchUsingLowerVariable() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("urlLower.contains(\"github\")", entry));
}
@Test
void caseInsensitiveContentMatchUsingLowerVariable() throws FeedEntryFilterException {
Assertions.assertTrue(service.filterMatchesEntry("contentLower.contains(\"merge\")", entry));
}
@Test
void caseInsensitiveCategoriesMatchUsingLowerVariable() throws FeedEntryFilterException {
FeedEntryContent content = entry.getContent();
content.setCategories("Tech, Programming, Java");
entry.setContent(content);
Assertions.assertTrue(service.filterMatchesEntry("categoriesLower.contains(\"tech\")", entry));
}
@Nested
class Sandbox {
@Test
void sandboxBlocksSystemPropertyAccess() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("java.lang.System.getProperty(\"user.home\")", entry));
}
@Test
void sandboxBlocksRuntimeExec() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("java.lang.Runtime.getRuntime().exec(\"calc\")", entry));
}
@Test
void sandboxBlocksProcessBuilder() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("new java.lang.ProcessBuilder(\"cmd\").start()", entry));
}
@Test
void sandboxBlocksClassLoading() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("java.lang.Class.forName(\"java.lang.Runtime\")", entry));
}
@Test
void sandboxBlocksReflection() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("title.getClass().getMethods()", entry));
}
@Test
void sandboxBlocksFileAccess() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("new java.io.File(\"/etc/passwd\").exists()", entry));
}
@Test
void sandboxBlocksFileRead() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("java.nio.file.Files.readString(java.nio.file.Paths.get(\"/etc/passwd\"))", entry));
}
@Test
void sandboxBlocksNetworkAccess() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("new java.net.URL(\"http://evil.com\").openConnection()", entry));
}
@Test
void sandboxBlocksScriptEngine() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service
.filterMatchesEntry("new javax.script.ScriptEngineManager().getEngineByName(\"js\").eval(\"1+1\")", entry));
}
@Test
void sandboxBlocksThreadCreation() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("new java.lang.Thread().start()", entry));
}
@Test
void sandboxBlocksEnvironmentVariableAccess() {
Assertions.assertThrows(FeedEntryFilterException.class,
() -> service.filterMatchesEntry("java.lang.System.getenv(\"PATH\")", entry));
}
@Test
void sandboxBlocksExitCall() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("java.lang.System.exit(0)", entry));
}
@Test
void sandboxBlocksUndeclaredVariables() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("unknownVariable == \"test\"", entry));
}
@Test
void sandboxBlocksMethodInvocationOnStrings() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("title.toCharArray()", entry));
}
@Test
void sandboxBlocksArbitraryJavaMethodCalls() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("title.getBytes()", entry));
}
@Test
void sandboxOnlyAllowsDeclaredVariables() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("System", entry));
}
@Test
void sandboxBlocksConstructorCalls() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("new String(\"test\")", entry));
}
@Test
void sandboxBlocksStaticMethodCalls() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("String.valueOf(123)", entry));
}
@Test
void sandboxBlocksLambdaExpressions() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("() -> true", entry));
}
@Test
void sandboxBlocksObjectInstantiation() {
Assertions.assertThrows(FeedEntryFilterException.class, () -> service.filterMatchesEntry("java.util.HashMap{}", entry));
}
}
}

View File

@@ -103,7 +103,7 @@ class WebSocketIT extends BaseIT {
FeedModificationRequest req = new FeedModificationRequest();
req.setId(subscriptionId);
req.setName("feed-name");
req.setFilter("!title.contains('item 4')");
req.setFilter("!titleLower.contains('item 4')");
RestAssured.given().body(req).contentType(ContentType.JSON).post("rest/feed/modify").then().statusCode(HttpStatus.SC_OK);
AtomicBoolean connected = new AtomicBoolean();

View File

@@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test;
import org.xml.sax.InputSource;
import com.commafeed.TestConstants;
import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.FeedInfo;
import com.commafeed.frontend.model.Subscription;
@@ -263,4 +264,47 @@ class FeedIT extends BaseIT {
}
}
@Nested
class Filter {
@Test
void filterEntriesOnNewFeedItems() throws IOException {
// subscribe and wait for initial 2 entries
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
Entries initialEntries = getFeedEntries(subscriptionId);
Assertions.assertEquals(2, initialEntries.getEntries().size());
// set up a filter that excludes entries with "item 4" in the title
Subscription subscription = getSubscription(subscriptionId);
FeedModificationRequest req = new FeedModificationRequest();
req.setId(subscriptionId);
req.setName(subscription.getName());
req.setCategoryId(subscription.getCategoryId());
req.setPosition(subscription.getPosition());
req.setFilter("!titleLower.contains('item 4')");
RestAssured.given().body(req).contentType(ContentType.JSON).post("rest/feed/modify").then().statusCode(HttpStatus.SC_OK);
// verify filter is set
subscription = getSubscription(subscriptionId);
Assertions.assertEquals("!titleLower.contains('item 4')", subscription.getFilter());
// feed now returns 2 more entries (Item 3 and Item 4)
feedNowReturnsMoreEntries();
forceRefreshAllFeeds();
// wait for new entries to be fetched
Awaitility.await().atMost(Duration.ofSeconds(15)).until(() -> getCategoryEntries("all"), e -> e.getEntries().size() == 4);
// verify that Item 4 was marked as read because it matches the filter
Entries unreadEntries = RestAssured.given()
.get("rest/feed/entries?id={id}&readType=unread", subscriptionId)
.then()
.statusCode(HttpStatus.SC_OK)
.extract()
.as(Entries.class);
Assertions.assertEquals(3, unreadEntries.getEntries().size());
Assertions.assertTrue(unreadEntries.getEntries().stream().noneMatch(e -> e.getTitle().toLowerCase().contains("item 4")),
"Item 4 should be filtered out (marked as read)");
}
}
}