Skip to content

MAJOR: JarRemoteClassSource downloads multi-GB JARs with no size limits #54

@sfloess

Description

@sfloess

Severity: MAJOR

File: JarRemoteClassSource.java

Problem

JarRemoteClassSource downloads entire JAR files into memory/disk with NO size validation. Can cause OOM or fill up disk with multi-GB JARs.

Bugs

1. Line 119: Unlimited JAR download size

try (InputStream in = connection.getInputStream()) {
    Files.copy(in, tempJarPath, StandardCopyOption.REPLACE_EXISTING);
}

Problem: Downloads entire JAR file with no size check

Attack scenario:

  1. Attacker creates 10GB "JAR" file
  2. Points JClassLoader to malicious URL
  3. Files.copy() downloads entire 10GB to temp directory
  4. Fills up disk space
  5. System-wide failures when disk is full

Impact:

  • Disk space exhaustion
  • Temp directory fills up
  • System-wide disruption
  • Other applications fail when disk full

Fix: Check Content-Length before downloading:

private synchronized void ensureJarReady() throws IOException {
    if (closed) {
        throw new IllegalStateException("JarRemoteClassSource is closed");
    }

    if (jarFile != null) {
        return;
    }

    tempJarPath = Files.createTempFile("jclassloader-jar-", ".jar");

    retryPolicy.execute(() -> {
        URL url = new URL(jarUrl);
        URLConnection connection = url.openConnection();
        connection.setConnectTimeout(connectTimeoutMs);
        connection.setReadTimeout(readTimeoutMs);

        if (connection instanceof HttpURLConnection) {
            HttpURLConnection httpConnection = (HttpURLConnection) connection;
            configureSSL(httpConnection);
            configureAuthentication(httpConnection);

            int responseCode = httpConnection.getResponseCode();
            if (responseCode != HttpURLConnection.HTTP_OK) {
                throw new IOException("HTTP error code: " + responseCode + " for URL: " + url);
            }

            // Check size BEFORE downloading
            long contentLength = httpConnection.getContentLengthLong();
            if (contentLength > MAX_JAR_SIZE) {  // e.g., 100MB
                throw new IOException(
                    "JAR file too large: " + contentLength + " bytes (max " + MAX_JAR_SIZE + ")"
                );
            }
        }

        try (InputStream in = connection.getInputStream()) {
            Files.copy(in, tempJarPath, StandardCopyOption.REPLACE_EXISTING);
        }

        // Validate downloaded file size
        long actualSize = Files.size(tempJarPath);
        if (actualSize > MAX_JAR_SIZE) {
            Files.deleteIfExists(tempJarPath);
            throw new IOException(
                "Downloaded JAR exceeds size limit: " + actualSize + " bytes"
            );
        }

        return null;
    });

    // Open the JAR file
    jarFile = new JarFile(tempJarPath.toFile());
}

Add MAX_JAR_SIZE constant:

private static final long MAX_JAR_SIZE = 100 * 1024 * 1024;  // 100MB default

// Make it configurable via Builder pattern
public static class Builder {
    private long maxJarSize = MAX_JAR_SIZE;
    
    public Builder maxJarSize(long maxBytes) {
        if (maxBytes <= 0) {
            throw new IllegalArgumentException("Max JAR size must be > 0");
        }
        this.maxJarSize = maxBytes;
        return this;
    }
    
    // ... other builder methods ...
}

2. Lines 140-149: No size validation on JAR entries

try (InputStream in = jarFile.getInputStream(entry);
     ByteArrayOutputStream out = new ByteArrayOutputStream()) {

    byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
    int bytesRead;
    while ((bytesRead = in.read(buffer)) != -1) {
        out.write(buffer, 0, bytesRead);
    }

    return out.toByteArray();
}

Problem: Reads entire JAR entry into ByteArrayOutputStream with no size check

Scenario: JAR contains a class file that is actually multi-GB of garbage data

  • ByteArrayOutputStream allocates multi-GB
  • OutOfMemoryError

Fix: Check entry size before reading:

@Override
public byte[] loadClassData(String className) throws IOException {
    ensureJarReady();

    String entryName = ClassNameUtil.toClassFilePath(className);
    JarEntry entry = jarFile.getJarEntry(entryName);

    if (entry == null) {
        throw new IOException("Class not found in JAR: " + className);
    }

    long size = entry.getSize();
    if (size < 0) {
        // Unknown size - read with limit
        return readWithSizeLimit(jarFile.getInputStream(entry), MAX_CLASS_SIZE);
    }

    if (size > MAX_CLASS_SIZE) {  // e.g., 10MB
        throw new IOException(
            "Class file too large: " + size + " bytes (max " + MAX_CLASS_SIZE + ")"
        );
    }

    if (size > Integer.MAX_VALUE) {
        throw new IOException("Class file exceeds Java array limit: " + size);
    }

    try (InputStream in = jarFile.getInputStream(entry)) {
        byte[] data = new byte[(int)size];
        int totalRead = 0;
        
        while (totalRead < size) {
            int n = in.read(data, totalRead, (int)size - totalRead);
            if (n == -1) break;
            totalRead += n;
        }
        
        return data;
    }
}

private byte[] readWithSizeLimit(InputStream in, long maxSize) throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
    long totalRead = 0;
    int bytesRead;
    
    while ((bytesRead = in.read(buffer)) != -1) {
        totalRead += bytesRead;
        if (totalRead > maxSize) {
            throw new IOException("Entry exceeds maximum size: " + totalRead);
        }
        out.write(buffer, 0, bytesRead);
    }
    
    return out.toByteArray();
}

3. Lines 154-161: canLoad() triggers expensive JAR download

@Override
public boolean canLoad(String className) {
    try {
        ensureJarReady();  // ← Downloads entire JAR!
        String entryName = ClassNameUtil.toClassFilePath(className);
        return jarFile.getJarEntry(entryName) != null;
    } catch (IOException e) {
        return false;
    }
}

Problem: First call to canLoad() downloads entire JAR

Scenario:

JarRemoteClassSource source = new JarRemoteClassSource("https://example.com/huge.jar");
boolean can = source.canLoad("com.example.Test");  // ← Downloads entire JAR!

This defeats the purpose of canLoad() being a "lightweight" check.

Impact:

  • canLoad() is supposed to be fast
  • ClassSource interface documentation says canLoad() "may be expensive for remote sources"
  • But this is especially expensive - downloads ENTIRE JAR just to check one entry

Mitigation: This is actually acceptable behavior for JarRemoteClassSource because:

  1. JAR is downloaded once and cached
  2. Subsequent canLoad() calls are fast
  3. Alternative would be HTTP Range requests which are complex

Recommendation: Document this clearly:

/**
 * Checks if this source can load the specified class.
 * 
 * <p><b>Performance Note:</b> The first call to this method (or loadClassData())
 * will download the entire JAR file. Subsequent calls use the cached JAR and are fast.
 * The download is synchronized and happens only once per instance.</p>
 *
 * @param className The fully qualified class name to check
 * @return true if the class exists in the JAR, false otherwise
 */
@Override
public boolean canLoad(String className) {
    // ...
}

4. Lines 189-191: Swallows IOException on jarFile.close()

if (jarFile != null) {
    try {
        jarFile.close();
    } catch (IOException e) {
        // Continue to delete temp file
    }
}

Problem: Silently swallows exception

Better approach: Log the exception

if (jarFile != null) {
    try {
        jarFile.close();
    } catch (IOException e) {
        logger.warn("Failed to close JAR file: {} - {}", jarUrl, e.getMessage());
    }
}

Required Actions

  1. Add MAX_JAR_SIZE validation (default 100MB, configurable)
  2. Add MAX_CLASS_SIZE validation for JAR entries (default 10MB, configurable)
  3. Document canLoad() performance characteristics
  4. Log exception when jarFile.close() fails

Impact

Current code allows:

  • Multi-GB JAR downloads filling up disk
  • Multi-GB class entries causing OOM
  • Silent failures in cleanup

With fixes:

  • Bounded resource usage
  • Clear error messages
  • Better observability

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions