31. 10. 2013

Když Java nevěří svým vlastním jarům

Když Java nevěří svým vlastním jarům

Náš projekt je vnitropodniková aplikace, jejíž klientskou část tvoří tenký Swing klient - hlavní jar a 29 jarů knihoven třetích stran. Protože uživatelé si průběžně instalují aktualizace Javy, nainstalovali si i release JDK 7u45. U této verze ale dostali nepříjemné varování:
případně:

Všechny jary jsou samozřejmě podepsány certifikační autoritou (nikoli self-signed). Kde je problém?

Diagnostika

Aplikace toto varování nehlásila hned na začátku, ale až po přihlášení. Bylo tedy zřejmé, že problém nastane někdy za běhu, pravděpodobně při pokusu o classloading nějaké třídy. Nejrychlejší cesta k nalezení postiženého místa byla spustit aplikaci a poté, co vyskočí okno s varováním, se na ni napíchnout pomocí jconsole.
Mimochodem jsem při tom zjistil, že v případě, kdy jconsole nenalezne běžící lokální javovskou aplikaci (typicky vidí jen sama sebe), není nutné si hrát s remote připojením přes -Dcom.sun.management.jmxremote, ale stačí zavolat jconsole <PID> a po odklepnutí bezpečnostního dotazu to funguje taky.
V threaddumpech pak najdeme místo, kde aplikace čeká:

Stack trace:
com.sun.deploy.uitoolkit.ui.NativeMixedCodeDialog._show(Native Method)
com.sun.deploy.uitoolkit.ui.NativeMixedCodeDialog.access$000(Unknown Source)
com.sun.deploy.uitoolkit.ui.NativeMixedCodeDialog$2.run(Unknown Source)
java.security.AccessController.doPrivileged(Native Method)
com.sun.deploy.uitoolkit.ui.NativeMixedCodeDialog.showImmediately(Unknown Source)
com.sun.deploy.uitoolkit.ui.NativeMixedCodeDialog.show(Unknown Source)
com.sun.deploy.security.CPCallbackHandler.showMixedTrustDialog(Unknown Source)
com.sun.deploy.security.CPCallbackHandler.access$1100(Unknown Source)
com.sun.deploy.security.CPCallbackHandler$ParentCallback.checkAllowed(Unknown Source)
com.sun.deploy.security.CPCallbackHandler$ParentCallback.check(Unknown Source)
   - locked com.sun.deploy.security.CPCallbackHandler$ParentCallback@abcfd2e
com.sun.deploy.security.CPCallbackHandler$ParentCallback.access$1700(Unknown Source)
com.sun.deploy.security.CPCallbackHandler$ChildElement.checkResource(Unknown Source)
com.sun.deploy.security.DeployURLClassPath$JarLoader.checkResource(Unknown Source)
com.sun.deploy.security.DeployURLClassPath$JarLoader.getResource(Unknown Source)
com.sun.deploy.security.DeployURLClassPath$JarLoader.findResource(Unknown Source)
com.sun.deploy.security.DeployURLClassPath$1.next(Unknown Source)
com.sun.deploy.security.DeployURLClassPath$1.hasMoreElements(Unknown Source)
java.net.URLClassLoader$3$1.run(Unknown Source)
java.net.URLClassLoader$3$1.run(Unknown Source)
java.security.AccessController.doPrivileged(Native Method)
java.net.URLClassLoader$3.next(Unknown Source)
java.net.URLClassLoader$3.hasMoreElements(Unknown Source)
sun.misc.CompoundEnumeration.next(Unknown Source)
sun.misc.CompoundEnumeration.hasMoreElements(Unknown Source)
java.util.Collections.list(Unknown Source)
com.o2bs.rts.core.MainApp.getApplicationBuildVersion(MainApp.java:200)

Inkriminovaným místem je tedy zjišťování čísla buildu, které se zobrazuje na stavovém řádku aplikace! Jde konkrétně o kód:

List<URL> resources = Collections.list(Thread.currentThread().getContextClassLoader().getResources("META-INF/MANIFEST.MF"));

Výpisem resourců zjistíme, že seznam obsahuje kromě aplikačního jaru a jeho závislostí ještě systémové jary:

[jar:file:/C:/Program%20Files/Java/jre7/lib/javaws.jar!/META-INF/MANIFEST.MF,
jar:file:/C:/Program%20Files/Java/jre7/lib/deploy.jar!/META-INF/MANIFEST.MF,
jar:file:/C:/Program%20Files/Java/jre7/lib/plugin.jar!/META-INF/MANIFEST.MF,
jar:file:/C:/Program%20Files/Java/jre7/lib/deploy.jar!/META-INF/MANIFEST.MF,
...]

Ty samozřejmě nejsou podepsány certifikátem zákazníka. Očekával bych, že je bude Java považovat za důvěryhodné, zatímco zrovna tyto jary způsobují, že Java aplikaci považuje za tzv. mixed code. Zřejmě ale nejsem sám, koho to překvapuje. Poslední tři odkazy pěkně popisují i varianty řešení:

Řešení - zmírnit kontrolu v Javě

Java Control Panel -> Advanced -> Mixed code... -> Enable - hide warning and run with protections

Zaškrtnutí uvedené možnosti způsobí, že Java se bude chovat, jako by uživatel stiskl No resp. Don't block. Výhodou této možnosti je jednoduchost, nevýhodou je ovšem nutnost zásahu na straně uživatele. Vzhledem k většímu počtu a různé technické zdatnosti uživatelů se snažíme je obtěžovat technickými zásahy co nejméně a jen tehdy, není-li alternativa.

Řešení - označit jary jako Trusted-Library

Přidáním atributu Trusted-Library:true do manifestu každého jaru (našeho a závislostí, nikoli systémových) se docílí toho, že jar bude nahrán classloaderem, který je tolerantní k popisované situaci. Podle dokumentace by měl být tento class loader rodičem class loaderu z Java Web Start, ačkoli inspekcí výrazu Thread.currentThread().getContextClassLoader() a následných .getParent() jsem žádný rozdíl nezpozoroval a v obou případech (s vylepšením manifestu i bez něj) byl řetěz classloaderů tvořen objekty tříd com.sun.jnlp.JNLPClassLoader, com.sun.jnlp.JNLPClassLoader, com.sun.jnlp.JNLPPreverifyClassLoader, sun.misc.Launcher$AppClassLoader, sun.misc.Launcher$ExtClassLoader.
Výhodou této možnosti je oprava bez zásahu do kódu i do uživatelova nastavení. Nevýhodou je pouze subjektivní pocit, že atribut, jehož dokumentace jasně uvádí "The Trusted-Library attribute is used for applications and applets that are designed to allow untrusted components.", je zneužit pro workaround zcela odlišného problému.

Modifikace build skriptu

V případě volby tohoto řešení je také třeba počítat s tím, že se ten atribut musí do jarů nějak dostat. V našem případě se jedná o zásah do Maven buildu. Pro build hlavního jaru je řešení jednoduché: pro maven-jar-plugin přidat do configuration/archive následující XML:
<manifestEntries>
    <Trusted-Library>true</Trusted-Library>
</manifestEntries>
Složitější je přidání atributu do jarů závislostí. Neznám žádný specializovaný Maven plugin, který by uměl pracovat s existujícím jarem, proto jsem se se skřípěním zubů uchýlil k Antu přes maven-antrun-plugin. Ani s Antem ale není ještě vyhráno - problém vykonání nějaké operace na každém .jar souboru z dané množiny není řešitelný pomocí samotného tasku <jar update="true"> (nested element <fileset> totiž specifikuje soubory určené k zabalení do jaru, nikoli množinu jarů!). Většina rad na webu byla založena na použití elementu <foreach> z knihovny ant-contrib, to by ale vyžadovalo další závislosti a především by nás to ještě víc zabetonovalo v antovském antipatternu programování v XML. Naštěstí mezi nimi existovala i výjimka, na jejímž základě se nakonec podařilo implementovat funkcionalitu přes task <scriptdef>. Vzhledem k tomu, že rozšíření ant-nodeps už na projektu stejně je a build běží na JDK6, která obsahuje Javascript, nevyžaduje toto řešení žádný dodatečný artefakt a vypadá i relativně čitelně:
<scriptdef name="addTrustedLibraryIntoManifests" language="javascript">
<![CDATA[
    importClass(java.io.File);
    importClass(org.apache.tools.ant.taskdefs.Manifest);
    var baseDir = new File(self.getProject().getProperty("project.build.directory") + "\\dependencies");
    var files = baseDir.listFiles();
    //self.log("baseDir " + baseDir + ", length " + files.length);
    var jarCount = 0;
    for (j = 0; j < files.length; j++) {
        var filename = files[j];
        if (/.*\.jar$/.test(filename)) {
            var jarFile = new File(filename);
            var manifest = new Manifest();
            var mainSection = manifest.getMainSection();
            mainSection.addConfiguredAttribute(new Manifest.Attribute("Trusted-Library","true"));
            var jarTask = self.project.createTask("jar");       
            jarTask.setDestFile(jarFile);
            jarTask.setUpdate(true);
            jarTask.addConfiguredManifest(manifest);
            jarTask.execute();
            jarCount++;
        }
    }
    self.log("enhanced " + jarCount + " jars");
]]>
</scriptdef>
<addTrustedLibraryIntoManifests />

Tato zkušenost mne utvrdila v názoru, že Maven se svým deklarativním frameworkovým přístupem je slabá zbraň proti partyzánskému imperativnímu programování v XML, které tu bylo v době dominance Antu. O důvod víc přejít na Gradle, který jsem během rozhodování pro toto řešení měl také na paměti.

Řešení - přistupovat k jaru přímo

Místo volání getResources na classloaderu by bylo také možné získat požadované informace přímo:

import com.sun.jnlp.JNLPClassLoader;
import com.sun.javaws.jnl.JARDesc; 
JNLPClassLoader cl = (JNLPClassLoader)Thread.currentThread().getContextClassLoader();
JARDesc desc = cl.getLaunchDesc().getResources().getEagerOrAllJarDescs(true)[0];
URL url = desc.getLocation(); 
try (JarInputStream is = new JarInputStream(url.openStream(),false)) {
    System.out.println(is.getManifest().getMainAttributes().getValue("Implementation-Build"));
}

Tuto možnost uvádím jen pro úplnost, program jsem zkoušel pouze na úrovni inspekce běžící aplikace. Pokud by měl být v reálném kódu, pravděpodobně by se musela vyřešit viditelnost tříd z JWS a dále ošetřit možnost, že aplikace je spouštěná mimo JWS (typicky z IDE), aby nedošlo ke ClassCastException při castu na JNLPClassLoader. Celkově vidím užitečnost tohoto kódu jen pro troubleshooting a ladění, ne pro produkční nasazení.

Shrnutí

Nepochybuji, že Oracle klade důraz na bezpečnost a obzvlášť u posledních upgradů Javy to lze pocítit, viz zdůvodňování posledního odložení JDK8. Nicméně změny v minor updatech jsou nepříjemné a navíc se mi nechce jen tak přijmout, že tak elementární případ, jakým je aplikace se všemi podepsanými jary, se musí zprovoznit takovýmto workaroundem.
Nicméně problém se podařilo odstranit, s přihlédnutím k výhodám a nevýhodám jednotlivých možností řešení. Zajímalo by mne, jak se bude toto chování vyvíjet v dalších verzích. Pokud máte zkušenost s jednodušším postupem u některého kroku, budu rád, když se o něj podělíte v komentářích.