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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user