fix(ext): harden Piwigo export transport

Enforce structurally validated HTTPS endpoints and direct multipart
uploads without generated shell scripts.

Prevent command and multipart-header injection, redact credentials,
clear temporary password copies, and add hostile-input regression
tests.

Correct stale export package JavaDoc references.
This commit is contained in:
2026-07-30 23:18:22 +02:00
parent 87c59ab7fd
commit 54c6c3dd47
3 changed files with 494 additions and 136 deletions

View File

@@ -34,36 +34,30 @@
package zeroecho.ext.content.export;
import java.io.BufferedWriter;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.SequenceInputStream;
import java.io.Writer;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.Objects;
import javax.net.ssl.HttpsURLConnection;
import zeroecho.sdk.content.api.AbstractExportableDataContent;
/**
* {@code PiwigoExportDataContent} is a specialized exportable content class
* that supports uploading an image file to a Piwigo photo gallery server,
* either directly via HTTP POST or by generating platform-specific scripts for
* deferred uploading.
* that uploads an image file to a Piwigo photo gallery server using a
* structured HTTPS multipart request.
*
* <p>
* Depending on the export mode (RAW, BASH_SCRIPT, CMD_SCRIPT), it can:
* <ul>
* <li>Upload the image to a Piwigo server directly using HTTP
* multipart/form-data</li>
* <li>Generate a Bash script that decodes the base64-encoded image and uploads
* it using curl</li>
* <li>Generate a CMD (Windows batch) script that reconstructs the image using
* certutil and uploads it</li>
* </ul>
* Only {@link ExportMode#RAW} is supported. Script export is deliberately
* unavailable because credentials and request values must never be represented
* as executable shell text.
*
* <p>
* This class integrates with {@code AbstractExportableDataContent} and expects
@@ -76,159 +70,249 @@ import zeroecho.sdk.content.api.AbstractExportableDataContent;
* </p>
*/
class PiwigoExportDataContent extends AbstractExportableDataContent {
private static final String INVALID_ENDPOINT = "Invalid Piwigo endpoint.";
private static final String INVALID_FILE_NAME = "Invalid Piwigo image file name.";
private static final String UNSUPPORTED_MODE = "Piwigo export supports RAW mode only.";
private static final int BOUNDARY_BYTES = 18;
private static final SecureRandom RANDOM = new SecureRandom();
private final String imageFileName;
private final String piwigoUrl;
private final URI endpoint;
private final String username;
private final String password;
private final char[] password;
private final String albumId;
private final PiwigoTransport transport;
/**
* Constructs a new exportable Piwigo upload object.
*
* @param imageFileName the name of the image file to assign during upload or
* script output
* @param piwigoUrl the URL of the Piwigo API endpoint
* @param piwigoUrl the absolute HTTPS URI of the Piwigo API endpoint
* @param username the Piwigo username for authentication
* @param password the Piwigo password
* @param albumId the ID of the Piwigo album to which the image will be
* uploaded
* @throws IllegalArgumentException if the endpoint or image filename is not
* safe for the structured request
* @throws NullPointerException if any argument is {@code null}
*/
public PiwigoExportDataContent(String imageFileName, String piwigoUrl, String username, String password,
String albumId) {
this(imageFileName, piwigoUrl, username, password, albumId, new HttpsPiwigoTransport());
}
/* package */ PiwigoExportDataContent(String imageFileName, String piwigoUrl, String username, String password,
String albumId, PiwigoTransport transport) {
super();
this.imageFileName = imageFileName;
this.piwigoUrl = piwigoUrl;
this.username = username;
this.password = password;
this.albumId = albumId;
this.imageFileName = requireHeaderFileName(imageFileName);
this.endpoint = requireEndpoint(piwigoUrl);
this.username = Objects.requireNonNull(username, "Piwigo username is required.");
this.password = Objects.requireNonNull(password, "Piwigo password is required.").toCharArray();
this.albumId = Objects.requireNonNull(albumId, "Piwigo album ID is required.");
this.transport = Objects.requireNonNull(transport, "Piwigo transport is required.");
}
/**
* Returns an {@code InputStream} that provides either the raw upload stream, or
* a platform-specific script depending on the export mode.
* Selects the export mode.
*
* @return the resulting {@code InputStream}
* @throws IOException if reading the input or creating the stream fails
* <p>
* Piwigo requests support only {@link ExportMode#RAW}; executable script
* representations are rejected.
*
* @param mode the requested export mode
* @throws IllegalArgumentException if {@code mode} is not
* {@link ExportMode#RAW}
* @throws NullPointerException if {@code mode} is {@code null}
*/
@Override
public void setExportMode(ExportMode mode) {
ExportMode requiredMode = Objects.requireNonNull(mode, "Export mode is required.");
if (requiredMode != ExportMode.RAW) {
throw new IllegalArgumentException(UNSUPPORTED_MODE);
}
super.setExportMode(requiredMode);
}
/**
* Uploads the configured input through a structured HTTPS multipart request.
*
* @return the Piwigo response stream
* @throws IOException if the HTTPS upload fails
* @throws IllegalStateException if input has not been configured
*/
@Override
public InputStream getStream() throws IOException {
if (input == null) {
throw new IllegalStateException("Input not set.");
}
if (mode != ExportMode.RAW) {
throw new IllegalStateException(UNSUPPORTED_MODE);
}
char[] requestPassword = password.clone();
try {
return transport.upload(endpoint, imageFileName, username, requestPassword, albumId, input.getStream());
} finally {
java.util.Arrays.fill(requestPassword, '\0');
}
}
return switch (mode) {
case RAW -> performDirectUpload(input.getStream());
case BASH_SCRIPT -> generateBashScript(input.getStream());
case CMD_SCRIPT -> generateCmdScript(input.getStream());
};
@Override
public String toString() {
return "PiwigoExportDataContent[endpoint=" + endpoint.getScheme() + "://" + endpoint.getHost()
+ ", configured=true]";
}
private static URI requireEndpoint(String value) {
Objects.requireNonNull(value, "Piwigo endpoint is required.");
if (containsControl(value) || containsEncodedControl(value)) {
throw new IllegalArgumentException(INVALID_ENDPOINT);
}
URI candidate;
try {
candidate = URI.create(value);
} catch (IllegalArgumentException exception) {
candidate = null;
}
if (candidate == null || !candidate.isAbsolute() || candidate.isOpaque()
|| !"https".equalsIgnoreCase(candidate.getScheme())
|| candidate.getHost() == null || candidate.getHost().isBlank() || candidate.getRawAuthority() == null
|| candidate.getRawUserInfo() != null || candidate.getRawFragment() != null) {
throw new IllegalArgumentException(INVALID_ENDPOINT);
}
return candidate;
}
private static String requireHeaderFileName(String value) {
Objects.requireNonNull(value, "Piwigo image file name is required.");
if (value.isBlank() || containsControl(value) || value.indexOf('"') >= 0 || value.indexOf('\\') >= 0) {
throw new IllegalArgumentException(INVALID_FILE_NAME);
}
return value;
}
private static boolean containsControl(String value) {
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (character <= 0x1f || character == 0x7f) {
return true;
}
}
return false;
}
private static boolean containsEncodedControl(String value) {
int index = 0;
while (index + 2 < value.length()) {
if (value.charAt(index) == '%' && isHex(value.charAt(index + 1)) && isHex(value.charAt(index + 2))) {
int decoded = Character.digit(value.charAt(index + 1), 16) * 16
+ Character.digit(value.charAt(index + 2), 16);
if (decoded <= 0x1f || decoded == 0x7f) {
return true;
}
index += 3;
} else {
index++;
}
}
return false;
}
private static boolean isHex(char character) {
return Character.digit(character, 16) >= 0;
}
/**
* Performs a direct upload of the image data to the Piwigo server using a
* multipart/form-data HTTP POST request.
*
* @param dataStream the input stream of the binary image data
* @return the server's response stream
* @throws IOException if the upload fails
* Executes one validated Piwigo upload while retaining URI and value
* boundaries.
*/
private InputStream performDirectUpload(InputStream dataStream) throws IOException {
String boundary = "----Boundary" + System.currentTimeMillis();
/* package */
@FunctionalInterface
interface PiwigoTransport {
/**
* Uploads one image using already validated request metadata.
*
* @param endpoint absolute HTTPS endpoint
* @param imageFileName validated multipart filename
* @param username opaque username value
* @param password caller-owned password copy
* @param albumId opaque album value
* @param dataStream image data
* @return response stream
* @throws IOException if the transport fails
*/
InputStream upload(URI endpoint, String imageFileName, String username, char[] password, String albumId,
InputStream dataStream) throws IOException;
}
URL url = URI.create(piwigoUrl).toURL();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setDoOutput(true);
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
/**
* Default structured HTTPS multipart transport.
*/
private static final class HttpsPiwigoTransport implements PiwigoTransport {
@Override
public InputStream upload(URI endpoint, String imageFileName, String username, char[] password, String albumId,
InputStream dataStream) throws IOException {
byte[] boundaryRandom = new byte[BOUNDARY_BYTES];
RANDOM.nextBytes(boundaryRandom);
String boundary = "----ZeroEcho" + HexFormat.of().formatHex(boundaryRandom);
try (OutputStream out = conn.getOutputStream();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8))) {
HttpsURLConnection connection = (HttpsURLConnection) endpoint.toURL().openConnection();
connection.setDoOutput(true);
connection.setRequestMethod("POST");
connection.setChunkedStreamingMode(8192);
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
try (InputStream source = dataStream;
OutputStream output = connection.getOutputStream()) {
writeMultipart(output, boundary, imageFileName, username, password, albumId, source);
}
InputStream response = null;
try {
response = connection.getInputStream();
} catch (IOException exception) {
InputStream errorStream = connection.getErrorStream();
if (errorStream != null) {
return errorStream;
}
}
if (response == null) {
throw new IOException("Piwigo HTTPS upload failed.");
}
return response;
}
}
/* package */ static void writeMultipart(OutputStream output, String boundary, String imageFileName,
String username, char[] password, String albumId, InputStream source) throws IOException {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
writeFormField(writer, boundary, "method", "pwg.images.add");
writeFormField(writer, boundary, "username", username);
writeFormField(writer, boundary, "password", password);
writeSecretFormField(writer, boundary, "password", password);
writeFormField(writer, boundary, "category", albumId);
writer.write("--" + boundary + "\r\n");
writer.write("Content-Disposition: form-data; name=\"image\"; filename=\"" + imageFileName + "\"\r\n");
writer.write("Content-Type: image/jpeg\r\n\r\n");
writer.write("Content-Disposition: form-data; name=\"image\"; filename=\"");
writer.write(imageFileName);
writer.write("\"\r\nContent-Type: image/jpeg\r\n\r\n");
writer.flush();
dataStream.transferTo(out);
out.flush();
source.transferTo(output);
output.flush();
writer.write("\r\n--" + boundary + "--\r\n");
writer.flush();
}
InputStream responseStream;
try {
responseStream = conn.getInputStream();
} catch (IOException e) {
InputStream errorStream = conn.getErrorStream();
if (errorStream != null) {
return errorStream;
}
return new ByteArrayInputStream(("Error: " + e.getMessage()).getBytes(StandardCharsets.UTF_8));
}
return responseStream;
}
/**
* Writes a single form field as part of a multipart/form-data HTTP request.
*
* @param writer the writer to output the field to
* @param boundary the multipart boundary
* @param name the name of the form field
* @param value the value of the form field
* @throws IOException if writing fails
*/
private void writeFormField(Writer writer, String boundary, String name, String value) throws IOException {
private static void writeFormField(Writer writer, String boundary, String name, String value) throws IOException {
writer.write("--" + boundary + "\r\n");
writer.write("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n");
writer.write(value + "\r\n");
}
/**
* Generates a Bash script that reconstructs the image using a Base64 heredoc
* block and uploads it using {@code curl}.
*
* @param originalStream the original binary stream of the image
* @return a stream containing the complete shell script
*/
private InputStream generateBashScript(InputStream originalStream) {
InputStream header = new ByteArrayInputStream(("#!/bin/bash\nset -e\n\ncurl -X POST \"" + piwigoUrl + "\" \\\n"
+ " -F method=\"pwg.images.add\" \\\n" + " -F username=\"" + username + "\" \\\n" + " -F password=\""
+ password + "\" \\\n" + " -F category=\"" + albumId + "\" \\\n" + " -F image=@<(base64 -d <<'EOF'\n")
.getBytes(StandardCharsets.UTF_8));
@SuppressWarnings("resource")
InputStream body = new Base64Stream(originalStream, null, 76, new byte[] { 10 });
InputStream footer = new ByteArrayInputStream("EOF\n)\n".getBytes(StandardCharsets.UTF_8));
return new SequenceInputStream(new SequenceInputStream(header, body), footer);
}
/**
* Generates a CMD batch script that reconstructs the image using certutil and
* uploads it using {@code curl}.
*
* @param originalStream the original binary stream of the image
* @return a stream containing the complete Windows batch script
*/
private InputStream generateCmdScript(InputStream originalStream) {
InputStream header = new ByteArrayInputStream(
"@echo off\nsetlocal\necho -----BEGIN BASE64----- > tmp.b64\n".getBytes(StandardCharsets.UTF_8));
@SuppressWarnings("resource")
InputStream body = new Base64Stream(originalStream, "echo ".getBytes(), 76, " >> tmp.b64\r\n".getBytes());
InputStream footer = new ByteArrayInputStream(
("echo -----END BASE64----- >> tmp.b64\n" + "certutil -decode tmp.b64 \"" + imageFileName + "\" >nul\n"
+ "del tmp.b64\n" + "curl -X POST \"" + piwigoUrl + "\" ^\n" + " -F method=pwg.images.add ^\n"
+ " -F username=" + username + " ^\n" + " -F password=" + password + " ^\n" + " -F category="
+ albumId + " ^\n" + " -F image=@" + imageFileName + "\n" + "del \"" + imageFileName + "\"\n")
.getBytes(StandardCharsets.UTF_8));
return new SequenceInputStream(new SequenceInputStream(header, body), footer);
private static void writeSecretFormField(Writer writer, String boundary, String name, char... value)
throws IOException {
writer.write("--" + boundary + "\r\n");
writer.write("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n");
writer.write(value);
writer.write("\r\n");
}
}

View File

@@ -36,10 +36,10 @@
*
* <p>
* This package provides streaming utilities and exportable content
* implementations that render {@link zeroecho.ext.content.api.DataContent} for
* implementations that render {@link zeroecho.sdk.content.api.DataContent} for
* deployment to external platforms or for script-based transport. Exports can
* be produced as raw bytes or as platform-specific scripts according to
* {@link zeroecho.ext.content.api.ExportableDataContent.ExportMode}.
* {@link zeroecho.sdk.content.api.ExportableDataContent.ExportMode}.
* </p>
*
* <h2>Scope</h2>
@@ -60,9 +60,9 @@
* <li><i>Piwigo uploader</i> - an exportable content implementation
* (package-private) that can either upload an image directly to a Piwigo server
* or generate Bash/CMD scripts that reconstruct and upload the image. It is
* built on {@link zeroecho.ext.content.api.AbstractExportableDataContent} and
* built on {@link zeroecho.sdk.content.api.AbstractExportableDataContent} and
* honors
* {@link zeroecho.ext.content.api.ExportableDataContent.ExportMode}.</li>
* {@link zeroecho.sdk.content.api.ExportableDataContent.ExportMode}.</li>
* </ul>
*
* <h2>Typical usage</h2>
@@ -87,7 +87,7 @@
*
* <h2>Security notes</h2>
* <ul>
* <li>Prefer exporting {@link zeroecho.ext.content.api.EncryptedContent} when
* <li>Prefer exporting {@link zeroecho.sdk.content.api.EncryptedContent} when
* targeting untrusted destinations.</li>
* <li>Avoid embedding secrets in scripts; pass credentials via environment
* variables or secure stores when possible.</li>
@@ -98,8 +98,8 @@
* <h2>Extensibility</h2>
* <ul>
* <li>New deployers should extend
* {@link zeroecho.ext.content.api.AbstractExportableDataContent} and select a
* default {@link zeroecho.ext.content.api.ExportableDataContent.ExportMode}
* {@link zeroecho.sdk.content.api.AbstractExportableDataContent} and select a
* default {@link zeroecho.sdk.content.api.ExportableDataContent.ExportMode}
* appropriate for the platform.</li>
* <li>Utilities like {@link Base64Stream} can be reused to generate
* platform-friendly payloads without buffering whole files.</li>

View File

@@ -0,0 +1,274 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.ext.content.export;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import zeroecho.sdk.content.api.ExportableDataContent.ExportMode;
class PiwigoExportDataContentTest {
private static final String CREDENTIAL_SENTINEL = "M7_SECRET_5c9d7f";
private static final byte[] IMAGE = { 0x01, 0x23, 0x45, 0x67 };
@Test
void validStructuredUploadPreservesOpaqueValues() throws Exception {
System.out.println("validStructuredUploadPreservesOpaqueValues");
RecordingTransport transport = new RecordingTransport();
String fileName = "photo ; $() && 日本語.jpg";
String password = CREDENTIAL_SENTINEL + " ;&&||>$()`\r\n";
PiwigoExportDataContent content = new PiwigoExportDataContent(fileName,
"https://gallery.example.test/piwigo/ws.php?format=json", "user name | value", password,
"Album Ω", transport);
content.setInput(() -> new ByteArrayInputStream(IMAGE));
byte[] response;
try (InputStream input = content.getStream()) {
response = input.readAllBytes();
}
assertEquals(URI.create("https://gallery.example.test/piwigo/ws.php?format=json"), transport.endpoint);
assertEquals(fileName, transport.imageFileName);
assertEquals("user name | value", transport.username);
assertEquals(password, new String(transport.passwordCopy));
assertEquals("Album Ω", transport.albumId);
assertArrayEquals(IMAGE, transport.image);
assertArrayEquals("ok".getBytes(StandardCharsets.UTF_8), response);
assertTrue(allZero(transport.passwordArgument), "Request password copy must be cleared after transport use.");
System.out.println("...endpoint host: " + transport.endpoint.getHost());
System.out.println("...opaque filename length: " + fileName.length());
System.out.println("...ok");
}
@Test
void multipartEncodingPreservesSafeSemantics() throws Exception {
System.out.println("multipartEncodingPreservesSafeSemantics");
ByteArrayOutputStream output = new ByteArrayOutputStream();
char[] password = "opaque-password".toCharArray();
PiwigoExportDataContent.writeMultipart(output, "ZeroEchoBoundary", "image name Ω.jpg", "user name",
password, "album Ω", new ByteArrayInputStream(IMAGE));
byte[] encoded = output.toByteArray();
String prefix = new String(encoded, 0, encoded.length - IMAGE.length - "\r\n--ZeroEchoBoundary--\r\n".length(),
StandardCharsets.UTF_8);
assertTrue(prefix.contains("name=\"method\"\r\n\r\npwg.images.add\r\n"));
assertTrue(prefix.contains("name=\"username\"\r\n\r\nuser name\r\n"));
assertTrue(prefix.contains("name=\"password\"\r\n\r\nopaque-password\r\n"));
assertTrue(prefix.contains("name=\"category\"\r\n\r\nalbum Ω\r\n"));
assertTrue(prefix.contains("name=\"image\"; filename=\"image name Ω.jpg\"\r\n"));
assertTrue(containsSubsequence(encoded, IMAGE));
System.out.println("...multipart bytes: " + encoded.length);
System.out.println("...ok");
}
@Test
void rejectsUnsafeEndpointsConsistently() {
System.out.println("rejectsUnsafeEndpointsConsistently");
String[] endpoints = {
"http://gallery.example.test/ws.php",
"ftp://gallery.example.test/ws.php",
"file:///tmp/ws.php",
"jar:https://gallery.example.test/archive.jar!/ws.php",
"relative/ws.php",
"//gallery.example.test/ws.php",
"https:///ws.php",
"https://user:pass@gallery.example.test/ws.php",
"https://gallery.example.test/ws.php#fragment",
"https://gallery.example.test/\rheader",
"https://gallery.example.test/\nheader",
"https://gallery.example.test/\0header",
"https://gallery.example.test/%0dheader",
"https://gallery.example.test/%0Aheader",
"https://gallery.example.test/%00header",
"https:////gallery.example.test/ws.php",
"https://:443/ws.php"
};
for (String endpoint : endpoints) {
IllegalArgumentException direct = assertThrows(IllegalArgumentException.class,
() -> new PiwigoExportDataContent("image.jpg", endpoint, "user", "password", "album"));
IllegalArgumentException injected = assertThrows(IllegalArgumentException.class,
() -> new PiwigoExportDataContent("image.jpg", endpoint, "user", "password", "album",
new RecordingTransport()));
assertEquals("Invalid Piwigo endpoint.", direct.getMessage());
assertEquals(direct.getMessage(), injected.getMessage());
}
System.out.println("...rejected endpoint cases: " + endpoints.length);
System.out.println("...ok");
}
@Test
void rejectsMultipartHeaderInjection() {
System.out.println("rejectsMultipartHeaderInjection");
String[] fileNames = {
"image\rX-Injected: true.jpg",
"image\nX-Injected: true.jpg",
"image\r\n--boundary.jpg",
"image\0.jpg",
"image\"; name=\"injected.jpg",
"image\\\"; filename=\"injected.jpg"
};
for (String fileName : fileNames) {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> new PiwigoExportDataContent(fileName, "https://gallery.example.test/ws.php", "user",
"password", "album"));
assertEquals("Invalid Piwigo image file name.", exception.getMessage());
}
System.out.println("...rejected header cases: " + fileNames.length);
System.out.println("...ok");
}
@Test
void rejectsExecutableScriptModes() {
System.out.println("rejectsExecutableScriptModes");
PiwigoExportDataContent content = new PiwigoExportDataContent("image.jpg",
"https://gallery.example.test/ws.php", "user", "password", "album", new RecordingTransport());
IllegalArgumentException bash = assertThrows(IllegalArgumentException.class,
() -> content.setExportMode(ExportMode.BASH_SCRIPT));
IllegalArgumentException command = assertThrows(IllegalArgumentException.class,
() -> content.setExportMode(ExportMode.CMD_SCRIPT));
assertEquals("Piwigo export supports RAW mode only.", bash.getMessage());
assertEquals(bash.getMessage(), command.getMessage());
assertEquals(ExportMode.RAW, content.getExportMode());
System.out.println("...authoritative mode: RAW");
System.out.println("...ok");
}
@Test
void redactsCredentialsFromDiagnosticsAndLogs() throws Exception {
System.out.println("redactsCredentialsFromDiagnosticsAndLogs");
RecordingTransport transport = new RecordingTransport();
PiwigoExportDataContent content = new PiwigoExportDataContent("image.jpg",
"https://gallery.example.test/ws.php", "user", CREDENTIAL_SENTINEL, "album", transport);
content.setInput(() -> new ByteArrayInputStream(IMAGE));
List<String> messages = new ArrayList<>();
Handler handler = new Handler() {
@Override
public void publish(LogRecord record) {
messages.add(record.getMessage());
}
@Override
public void flush() {
// Nothing buffered.
}
@Override
public void close() {
// Nothing owned.
}
};
Logger logger = Logger.getLogger("");
logger.addHandler(handler);
try (InputStream ignored = content.getStream()) {
assertFalse(content.toString().contains(CREDENTIAL_SENTINEL));
assertFalse(new String(ignored.readAllBytes(), StandardCharsets.UTF_8).contains(CREDENTIAL_SENTINEL));
} finally {
logger.removeHandler(handler);
}
assertTrue(messages.stream().noneMatch(message -> message != null && message.contains(CREDENTIAL_SENTINEL)));
IllegalArgumentException invalid = assertThrows(IllegalArgumentException.class,
() -> new PiwigoExportDataContent("image.jpg", "https://example.test/%0d", "user",
CREDENTIAL_SENTINEL, "album"));
assertFalse(invalid.getMessage().contains(CREDENTIAL_SENTINEL));
System.out.println("...diagnostic fields checked: 4");
System.out.println("...ok");
}
private static boolean allZero(char[] value) {
for (char character : value) {
if (character != '\0') {
return false;
}
}
return true;
}
private static boolean containsSubsequence(byte[] source, byte[] expected) {
for (int start = 0; start <= source.length - expected.length; start++) {
if (Arrays.equals(Arrays.copyOfRange(source, start, start + expected.length), expected)) {
return true;
}
}
return false;
}
private static final class RecordingTransport implements PiwigoExportDataContent.PiwigoTransport {
private URI endpoint;
private String imageFileName;
private String username;
private char[] passwordArgument;
private char[] passwordCopy;
private String albumId;
private byte[] image;
@Override
public InputStream upload(URI requestEndpoint, String requestImageFileName, String requestUsername,
char[] requestPassword, String requestAlbumId, InputStream dataStream) throws IOException {
endpoint = requestEndpoint;
imageFileName = requestImageFileName;
username = requestUsername;
passwordArgument = requestPassword;
passwordCopy = requestPassword.clone();
albumId = requestAlbumId;
try (InputStream source = dataStream) {
image = source.readAllBytes();
}
return new ByteArrayInputStream("ok".getBytes(StandardCharsets.UTF_8));
}
}
}