Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clean production classpath #90

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

vmj
Copy link
Contributor

@vmj vmj commented Dec 30, 2024

NOTE: This is probably a breaking change for some users. Also, this is using incubating Gradle APIs. I just wanted to create a POC and get your opinnion on this whole thing.

gwt-dev brings 55 additional JAR files with it. 32 of them contain reported vulnerabilities, totaling at 246 CVEs. The last GWT release (2.11 -> 2.12.1) did not update a single one of these dependencies. (For details, see https://github.com/vmj/gwt-dev-vulnerabilities)

In my project, virtually all of those transitive dependencies are not actually used at production runtime. gwt-user is what we need.

Because I have hard time proving to my clients that they can ignore those CVEs, I created this patch. It moves the gwt-dev and gwt-codeserver to a separate dependency scope, which is then added where needed. That is, gwtCompile, gwtDevMode, gwtSuperDev and any Test tasks that requested GWT capabilities.

If you agree that this would be good way to go, let me know. I can try to find a non-incubating way of doing the same. I'm not sure what to do to the "breaking change" part, though.

@jiakuan
Copy link
Owner

jiakuan commented Dec 31, 2024

Just wanted to understand more about the context of the issue. Is the gwt-dev jar included in a deployable server application?

Just wondering, with the current version, are you able to exclude gwt-dev from the runtime in your project gradle build script?

for example:

configurations {
    runtimeClasspath {
        exclude group: 'org.gwtproject', module: 'gwt-dev'
    }
}

It seems a good idea to use separate dependency scopes to solve this runtime dependency issue. Also, would it be easy to arrange a minimum example to test the behaviour and gradle versions?

@vmj
Copy link
Contributor Author

vmj commented Dec 31, 2024

Just wanted to understand more about the context of the issue.

Sure!

Is the gwt-dev jar included in a deployable server application?

It has been included in our WAR-file for years, yes. And it seems to be a habbit in other projects, too. For example, we have dependencies on some GWT-libraries like com.github.gwtmaterialdesign:gwt-material:2.8.3. And they tend have transitive dependency on gwt-dev, too. Not all of them do, though. E.g. com.allen-sauer.gwt.dnd:gwt-dnd:3.3.3 doesn't.

But due to the sad list of vulns, we were thinking whether we really need it or any of its deps. So I've been doing some preliminary testing using the patched gwt-gradle-plugin, and it seems that it is not needed in the deployed server app. I was quite sure I've seen a mention of it in some GWT documentation, too, but now I can't find that.

Just wondering, with the current version, are you able to exclude gwt-dev from the runtime in your project gradle build script?

for example:

configurations {
    runtimeClasspath {
        exclude group: 'org.gwtproject', module: 'gwt-dev'
    }
}

I will try and let you know how far I can get with this approach. Until then, happy new year!

@vmj
Copy link
Contributor Author

vmj commented Jan 3, 2025

Is the gwt-dev jar included in a deployable server application?

It has been included in our WAR-file for years, yes.

But due to the sad list of vulns, we were thinking whether we really need it or any of its deps.
I was quite sure I've seen a mention of it in some GWT documentation, too, but now I can't find that.

I've found some references.

The most compelling source is a mailing list post from Oct 2024 by Colin Alworth, the main contributor to GWT. The dependencies are also mentioned in Deploying on a servlet container using RPC and Deploying RPC. You can ignore the text about the Apache Ant build.xml and the deprecated webAppCreator utility. I don't know how worthy it is, but this thing is also mentioned on StackOverflow.

My interpretation is, that

  • if GWT is used just for the frontend (no RPC), then no JARs are needed to deploy the app (might be obvious)
  • if the app is using GWT RPC (or RequestFactory), then the deployed server side app needs gwt-servlet.jar, on both compile and runtime classpaths (so, implementation dependency scope)
  • at build time, the GWT compiler needs gwt-dev (the compiler proper) and gwt-user (the widgets et all; these I guess will get included in the compiler output, as JavaScript)
  • at development time, gwt-codeserver is needed for the gwtSuperDev (it, in turn, probably needs the compiler)
  • not sure about the gwtDevMode since I've never used it

So, not even gwt-user is needed in production.

... are you able to exclude gwt-dev from the runtime in your project gradle build script?
for example:

configurations {
    runtimeClasspath {
        exclude group: 'org.gwtproject', module: 'gwt-dev'
    }
}

I will try and let you know how far I can get with this approach. Until then, happy new year!

This didn't really work, because it removes gwt-dev from gwtCompile task runtime classpath, too. So the task cannot find the compiler main class.

If I would take all this to the extreme, it might look like this:

  • add gwt-dev and gwt-user to gwtCompile task's dedicated runtime classpath
  • add gwt-codeserver, gwt-dev, and gwt-user to gwtSuperDev task's dedicated runtime classpath
  • add gwt-servlet to implementation dependency scope
    • give the plugin user (build author) the option to disable this (i.e. "my app is not using GWT RPC" flag, though most apps probably are)

@jiakuan
Copy link
Owner

jiakuan commented Jan 3, 2025

The gwtDevMode task uses Super Dev Mode by default in the latest GWT, providing similar functionality to gwtSuperDev. It also supports the classic (old) Dev Mode, though I doubt anyone uses it anymore. The gwtSuperDev task relies on a main class in gwt-codeserver.jar, but gwtDevMode might not require gwt-codeserver.jar (this needs testing).

Yes, I suppose most GWT use cases are limited to the frontend, but gwt-servlet is necessary if RPC is used.

Your suggestion looks great. I think we can configure gwt-user as compileOnly and add other dependencies to the runtime of their corresponding tasks, as you suggested.

@jiakuan
Copy link
Owner

jiakuan commented Jan 4, 2025

If GWT RPC is not being used, it's a good idea to completely separate the frontend from the backend code.

For example, only deploy the compiled JS and related files to a location where it can work seamlessly with the backend.

In this way, the backend will never mess up with the dependencies of the frontend. But for legacy projects which have GWT code sitting together with the server side code, it'd be great to have fine control over the GWT dependencies in this plugin.

@jnehlmeier
Copy link

During development you need

  • gwt-user.jar (JRE emulation, Widgets, etc)
  • gwt-dev.jar (compiler, dev mode)
  • gwt-codeserver.jar (Jetty based server that calls GWT compiler and serves the compile output. Depends on gwt-dev.jar)
  • Any 3rd party GWT library (e.g. GWT UI Frameworks, elemental2)

Non of these should ever be deployed in a *.war file.

If needed, you only need to deploy (and their transitive dependencies):

  • gwt-servlet.jar / gwt-servlet-jakarta.jar, if you use GWT-RPC
  • requestfactory-server.jar, if you use RequestFactory

If you create a gradle project with just a single module that combines GWT and web application then you have the above problem, because now you only have a single classpath for GWT compilation and for your web application. E.g. your build.gradle file looks like:

plugins {
  id 'war'
  id 'org.docstr.gwt'
}

In that case you would need a dedicated configuration, e.g. named gwt, put all GWT dependencies into that gwt configuration and use the configuration to populate the classpath of any GWT related task (SuperDevMode, DevMode, GWT compile). That is basically what this PR does.

A better setup is to split that single gradle module into two gradle modules, e.g. gwt-ui and webapp, and then include the various GWT compiler outputs of gwt-ui in the war task of the webapp module. Then you automatically have two dedicated classpaths and you can put all GWT related dependencies in the gwt-ui module in scope implementation and there is no need for the changes done in this PR.

Once you have two gradle modules you need to figure out a way to share the gwt compiler outputs of gwt-ui module with the webapp modules war task. To do so you should use configurations, see below.

multi-module-project/settings.gradle:

rootProject.name = 'multi-module-project'

include ':gwt-ui'
include ':webapp'

multi-module-project/gwt-ui/build.gradle:

plugins {
    id 'java'
    id "org.docstr.gwt" version "2.1.6"
}

group = 'com.example.gwt'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
   // your GWT dependencies, e.g. guava-gwt
   // implementation "..."
}

gwt {
    modules = ['com.example.gwt.GwtUi']
    // Relocate some GWT outputs so they each have a dedicated folder.
    // This works better when sharing the GWT output with other projects because
    // you can precisely control what to share.

    // The final JS App
    war = file("${layout.buildDirectory}/gwt/js")
    // Server side GWT data, e.g. symbol maps, sourcemaps
    deploy = file("${layout.buildDirectory}/gwt/deploy")
    // Source files generated by GWT compiler
    gen = file("${layout.buildDirectory}/gwt/gen")
    // Compile report as information for the GWT developer
    extra = file("${layout.buildDirectory}/gwt/extra")
}

// Additional configurations each holding a single zip file with
// the corresponding GWT compiler output.
configurations {
    gwtJsZipFile
    gwtDeployZipFile
}

// Zip task to package GWT JS output
tasks.register("zipGwtJs", Zip) {
    dependsOn("gwtCompile")
    from (gwt.war)
    destinationDirectory = layout.buildDirectory.dir('gwt')
    archiveBaseName = "${project.name}-js"
}

// Zip task to package GWT deploy output
tasks.register("zipGwtDeploy", Zip) {
    dependsOn("gwtCompile")
    from (gwt.deploy)
    destinationDirectory = layout.buildDirectory.dir('gwt')
    archiveBaseName = "${project.name}-deploy"
}

// Add each zip file as artifact to its corresponding configuration.
// These can then be consumed by other projects.
artifacts {
    gwtJsZipFile tasks.named("zipGwtJs")
    gwtDeployZipFile tasks.named("zipGwtDeploy")
}

multi-module-project/webapp/build.gradle:

plugins {
    id 'java'
    id 'war'
}

group = 'com.example.gwt'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

// Additional configurations that each will hold a single zip file
// with the corresponding GWT compiler output
configurations {
    gwtJsZipFile
    gwtDeployZipFile
}

dependencies {
    // web app dependencies
    compileOnly 'javax.servlet:javax.servlet-api:3.1.0'

    // Depend on the zip files produced by 'gwt-ui' module by depending
    // on the corresponding configurations.
    // These should be unzipped into the final war file
    gwtJsZipFile project(path: ':gwt-ui', configuration: 'gwtJsZipFile')
    gwtDeployZipFile project(path: ':gwt-ui', configuration: 'gwtDeployZipFile')
}

war {
    // Ensure that 'gwt-ui' has been compiled and zip files are ready
    dependsOn(configurations.gwtJsZipFile)
    dependsOn(configurations.gwtDeployZipFile)
    // include the JS app to the root of the war file
    into ('/') {
        from zipTree(configurations.gwtJsZipFile.singleFile)
    }
    // include the deploy files to WEB-INF/deploy as they are only used by the server,
    // e.g. during client exception stack trace deobfuscation (if the JS app sends the
    // stack trace to the server)
    into ('WEB-INF/deploy') {
        from zipTree(configurations.gwtDeployZipFile.singleFile)
    }
}

With the above the final war file will never have any GWT libaries packaged because it only depends on the GWT JS output. An alternative to using Gradle configurations is to use Gradle variants but I think configurations are a bit easier to setup and are good enough in this situation here.

@vmj
Copy link
Contributor Author

vmj commented Jan 4, 2025

Thank you @jnehlmeier for a comprehensive example. So the original issue (gwt-dev in prod classpath) is avoidable with the current version of this plugin by splitting our app into multiple Gradle modules. I will need to bring this up as an option to my team.

As a bit of a sidetrack. @jiakuan you asked about testing Gradle versions. See commit 37896d0d in my clone. I didn't, yet, create a PR from it. I don't know how you feel about exploding your CI traffic or cache size: the test I modified downloads and caches all those Gradle versions mentioned in there.

@jiakuan
Copy link
Owner

jiakuan commented Jan 4, 2025

@jnehlmeier Great explanation and examples! If you don’t mind, I’m going to move this content into a separate document and make some adjustments. I’ve been using the exact same multi-module setup, and we haven’t encountered the dependency issues mentioned above — though we do configure the war output directory directly, something like:

war = file('../my-webapp/src/main/webapp')

@vmj As long as GitHub Actions can handle it, automating compatibility tests for the latest five to ten major versions of Gradle would be great. The CI execution time should be fine, and we could run these checks in a separate GitHub workflow.

However, if it becomes too cumbersome, we can simply note in the documentation that certain Gradle versions may not work, and encourage users to report any related issues. Because Gradle updates so quickly, it’s not always feasible to cover every version in our tests.

Thoughts? If you’d like, you can commit these changes to this PR and we can test them out.

Regarding this PR:
It offers flexibility to users who want to keep their GWT code together with the server-side code. I found this particularly useful when using GWT RPC, as I could place common interfaces in the shared package for both the client and backend code.

Question:
Do we have any Gradle configuration snippets that demonstrate how to use the custom configuration provided by this PR? Ideally, we could include a minimal example in the examples directory to show how it works (the CI will build the examples). Then, after merging, I’d like to update the documentation to reflect these changes.

@jnehlmeier
Copy link

@jiakuan Sure go ahead. The only inconvenience I encountered while creating the example was that I had to reconfigure the default war folder provided by your plugin.

It seems like that gwt.war points to build/gwt and gwt.deploy|extra are mapped to a subfolder of build/gwt. So we end up with

build/gwt-unitCache
build/gwt
  -> <module-a JS>
  -> <module-b JS>
  -> ...
  -> deploy
  -> extra

In order to easily zip the JS output without deploy and extra I had to relocate the war folder (and kept the others in the example for clarity). We don't want extra on the server and deploy typically ends up in WEB-INF/deploy instead of the root of the war file.

Maybe changing the default would make sense. Also gwt-unitCache can probably be relocated to build/gwt/gwt-unitCache to keep things together.

The example assumes that you will deploy the file *.war file instead of using some exploded war directory or writing in the source tree of the webapp projects.

@vmj vmj force-pushed the clean-production-classpath branch from f638e3a to d7cb1f9 Compare January 5, 2025 13:05
@vmj
Copy link
Contributor Author

vmj commented Jan 5, 2025

I have updated the code:

  • Removed the use of incubating Gradle APIs: it now works with Gradle 8.1, just like the main branch
  • Added a basic-gwt-rpc example app: a single Gradle module, GWT RPC, Jakarta Servlet API
  • Updated the gradle-jvm-test-suites-with-gwt example app: a single Gradle module, GWT RPC, legacy Servlet API (and still using test suites)

I added a jakarta flag to the GWT extension, which allows the plugin user to indicate if they are still using the legacy servlet API.

I'm still adding the GWT dev et al to a single dependency scope shared by gwtCompile, gwtDevMode and gwtSuperDev. This is for simplicity. Seems like a bit over-engineering to split the codeserver to another dependency scope. But only the gwt-servlet is now added to production classpath.

And naturally I broke the GWTTestCase support. I'll look into it.

@jiakuan
Copy link
Owner

jiakuan commented Jan 6, 2025

@jiakuan Sure go ahead. The only inconvenience I encountered while creating the example was that I had to reconfigure the default war folder provided by your plugin.

It seems like that gwt.war points to build/gwt and gwt.deploy|extra are mapped to a subfolder of build/gwt. So we end up with

build/gwt-unitCache
build/gwt
  -> <module-a JS>
  -> <module-b JS>
  -> ...
  -> deploy
  -> extra

In order to easily zip the JS output without deploy and extra I had to relocate the war folder (and kept the others in the example for clarity). We don't want extra on the server and deploy typically ends up in WEB-INF/deploy instead of the root of the war file.

Maybe changing the default would make sense. Also gwt-unitCache can probably be relocated to build/gwt/gwt-unitCache to keep things together.

The example assumes that you will deploy the file *.war file instead of using some exploded war directory or writing in the source tree of the webapp projects.

Good idea! I've adjusted the default output directories and created a pull request.
#92

@vmj
Copy link
Contributor Author

vmj commented Jan 12, 2025

Unfortunately I haven't had time to revisit this for a week. And I doubt I will have time for another week.

If anyone wants to take a look at the GWTTestCase support that I broke in this branch in the meantime, here's where I ended up. The GwtTestConfig class does configure the Test task instance, by setting the test.setClasspath, but that only affects the test runtime. I couldn't figure out how to get from the Test instance to the correct dependency scope configuration. I mean, if the test instance comes from the java plugin, it would be the testImplementation dependency scope. But if the test instance is one of the jvm-test-suite test suites, then they should have their own dependency scopes, each named by the build author.

I should get back to this eventually, though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants