Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.util.Base64;


Expand Down Expand Up @@ -317,6 +319,35 @@ public JsonNode getUiSpec() {
return profileRegistryPlugin.getUISpecification();
}

private void validateFieldAndFile(MultipartFile file, String fieldName) {
JsonNode uiSpec = getUiSpec();
Set<String> allowedTypesForField = UploadFileUtils.getAcceptedTypesForField(uiSpec, fieldName);

if (allowedTypesForField.isEmpty()) {
log.error("Invalid field for file upload: {}",fieldName);
throw new SignUpException(ErrorConstants.INVALID_FIELD);
}

String detectedMimeType;
try (InputStream inputStream = file.getInputStream()) {
detectedMimeType = UploadFileUtils.detectMimeType(inputStream);
} catch (IOException e) {
log.error("Failed to read uploaded file", e);
throw new SignUpException(ErrorConstants.UPLOAD_FAILED);
}

if (UploadFileUtils.UNKNOWN_MIME_TYPE.equals(detectedMimeType)) {
log.error("Unrecognized file type for field: {}", fieldName);
throw new SignUpException(ErrorConstants.INVALID_FILE_TYPE);
}

if (!allowedTypesForField.contains(detectedMimeType)) {
log.error("Invalid file type for field: {}. Detected: {}, Allowed: {}",
fieldName, detectedMimeType, allowedTypesForField);
throw new SignUpException(ErrorConstants.INVALID_FILE_TYPE);
}
}

public RegisterResponse uploadFile(String transactionId, String fieldName, MultipartFile file) throws SignUpException {
RegistrationTransaction transaction = cacheUtilService.getChallengeVerifiedTransaction(transactionId);
if(transaction == null) {
Expand All @@ -330,6 +361,8 @@ public RegisterResponse uploadFile(String transactionId, String fieldName, Multi
throw new SignUpException(ErrorConstants.INVALID_REQUEST);
}

validateFieldAndFile(file, fieldName);

try {
RegistrationFiles registrationFiles = new RegistrationFiles();
registrationFiles.getUploadedFiles().put(fieldName, Base64.getEncoder().encodeToString(file.getBytes()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,6 @@ public class ErrorConstants {

public static final String TOKEN_REQUEST_FAILED = "token_request_failed";
public static final String UPLOAD_FAILED = "upload_failed";
public static final String INVALID_FILE_TYPE = "invalid_file_type";
public static final String INVALID_FIELD = "invalid_field";
}
147 changes: 147 additions & 0 deletions signup-service/src/main/java/io/mosip/signup/util/UploadFileUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package io.mosip.signup.util;
import com.fasterxml.jackson.databind.JsonNode;
import io.mosip.signup.exception.SignUpException;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import java.util.stream.StreamSupport;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

public class UploadFileUtils {
public static final String UNKNOWN_MIME_TYPE = "application/octet-stream";
private static final String ZIP_SIGNATURE = "504B0304";
private static final Map<String, String> MAGIC_SIGNATURES = Map.of(
"89504E47", "image/png",
"FFD8FF", "image/jpeg",
"25504446", "application/pdf",
"D0CF11E0", "application/msword"
);

public static String detectMimeType(InputStream inputStream) throws IOException {
if (inputStream == null) {
return UNKNOWN_MIME_TYPE;
}
// Wrap to support mark/reset for ZIP files
BufferedInputStream bis = new BufferedInputStream(inputStream);
bis.mark(Integer.MAX_VALUE);

byte[] headerBytes = new byte[12];
int bytesRead = bis.read(headerBytes);

if (bytesRead < 4) {
return UNKNOWN_MIME_TYPE;
}

StringBuilder hexBuilder = new StringBuilder();
for (int i = 0; i < bytesRead; i++) {
hexBuilder.append(String.format("%02X", headerBytes[i] & 0xFF));
}

String hexString = hexBuilder.toString();

// Check for WebP specifically (RIFF....WEBP pattern)
if (hexString.startsWith("52494646") && hexString.length() >= 24
&& hexString.substring(16, 24).equals("57454250")) {
return "image/webp";
}

// Check for ZIP-based formats
if (hexString.startsWith(ZIP_SIGNATURE)) {
bis.reset();
return detectOfficeFormat(bis);
}

// Check other signatures
for (Map.Entry<String, String> entry : MAGIC_SIGNATURES.entrySet()) {
if (hexString.startsWith(entry.getKey())) {
return entry.getValue();
}
}

return UNKNOWN_MIME_TYPE;
}

private static String detectOfficeFormat(InputStream inputStream) {
try (ZipInputStream zis = new ZipInputStream(inputStream)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String entryName = entry.getName();

// DOCX: contains word/document.xml
if ("word/document.xml".equalsIgnoreCase(entryName)) {
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
}
// PPTX: contains ppt/presentation.xml
if ("ppt/presentation.xml".equalsIgnoreCase(entryName)) {
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
}

zis.closeEntry();
}
} catch (IOException ignored) {
throw new SignUpException(ErrorConstants.UPLOAD_FAILED);
}
return UNKNOWN_MIME_TYPE;
}


public static Set<String> getAcceptedTypesForField(JsonNode root, String targetFieldName) {
if (root == null || targetFieldName == null || targetFieldName.isBlank()) {
return Collections.emptySet();
}

JsonNode schemaNode = root.get("schema");
if (schemaNode == null || !schemaNode.isArray()) {
return Collections.emptySet();
}

return StreamSupport.stream(schemaNode.spliterator(), false)
.filter(UploadFileUtils::isFileUploadField)
.filter(field -> targetFieldName.equals(getFieldId(field)))
.findFirst()
.map(field -> extractAcceptedTypes(field.get("acceptedFileTypes")))
.orElse(Collections.emptySet());
}

private static boolean isFileUploadField(JsonNode field) {
JsonNode controlTypeNode = field.get("controlType");
if (controlTypeNode == null || !controlTypeNode.isTextual()) {
return false;
}
String controlType = controlTypeNode.asText().trim();
return controlType.equalsIgnoreCase("photo") || controlType.equalsIgnoreCase("fileUpload");
}

private static String getFieldId(JsonNode field) {
JsonNode idNode = field.get("id");
if (idNode == null || !idNode.isTextual()) {
return null;
}
return idNode.asText().trim();
}

private static Set<String> extractAcceptedTypes(JsonNode acceptedFileTypesNode) {
if (acceptedFileTypesNode == null) return Collections.emptySet();

Set<String> acceptedTypes = new HashSet<>();

if (acceptedFileTypesNode.isArray()) {
for (JsonNode typeNode : acceptedFileTypesNode) {
if (typeNode.isTextual()) {
String trimmed = typeNode.asText().trim().toLowerCase(Locale.ROOT);
if (!trimmed.isEmpty()) acceptedTypes.add(trimmed);
}
}
} else if (acceptedFileTypesNode.isTextual()) {
Arrays.stream(acceptedFileTypesNode.asText().split(","))
.map(s -> s.trim().toLowerCase(Locale.ROOT))
.filter(s -> !s.isEmpty())
.forEach(acceptedTypes::add);
}

return acceptedTypes.isEmpty() ? Collections.emptySet() : acceptedTypes;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
package io.mosip.signup.services;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.mosip.esignet.core.exception.EsignetException;
Expand Down Expand Up @@ -1885,20 +1886,138 @@ public void getUiSpec_withException_theFail() {
}

@Test
public void uploadFile_withValidTransaction_thenPass() {
public void uploadFile_withValidTransaction_thenPass() throws JsonProcessingException {
String transactionId = "txn-123";
String fieldName = "photo";
byte[] fileContent = "test-image".getBytes();
MultipartFile file = new MockMultipartFile("file", fileContent);
byte[] pngBytes = new byte[]{
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
};
MultipartFile file = new MockMultipartFile("file", "photo.png", "image/png", pngBytes);

RegistrationTransaction transaction = new RegistrationTransaction("user", Purpose.REGISTRATION);
when(cacheUtilService.getChallengeVerifiedTransaction(transactionId)).thenReturn(transaction);

String uiSpecJson = """
{
"schema": [
{
"id": "photo",
"controlType": "fileupload",
"acceptedFileTypes": ["image/jpeg", "image/png"]
}
]
}
""";
JsonNode uiSpecNode = objectMapper.readTree(uiSpecJson);
when(profileRegistryPlugin.getUISpecification()).thenReturn(uiSpecNode);

RegisterResponse response = registrationService.uploadFile(transactionId, fieldName, file);

Assert.assertNotNull(response);
Assert.assertEquals(ActionStatus.UPLOADED, response.getStatus());
verify(cacheUtilService, times(1)).setRegistrationFiles(eq(transactionId), any(RegistrationFiles.class));
verify(profileRegistryPlugin, times(1)).getUISpecification();
}

@Test
public void uploadFile_withUnrecognizedFileType_throwsInvalidFileType() throws JsonProcessingException {
String transactionId = "txn-123";
String fieldName = "photo";
// Random bytes that don't match any known file signature
byte[] unknownBytes = new byte[]{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
MultipartFile file = new MockMultipartFile("file", "unknown.bin", "application/octet-stream", unknownBytes);

RegistrationTransaction transaction = new RegistrationTransaction("user", Purpose.REGISTRATION);
when(cacheUtilService.getChallengeVerifiedTransaction(transactionId)).thenReturn(transaction);

// UI spec with "photo" field that accepts image types
String uiSpecJson = """
{
"schema": [
{
"id": "photo",
"controlType": "photo",
"acceptedFileTypes": ["image/png", "image/jpeg"]
}
]
}
""";
JsonNode uiSpecNode = objectMapper.readTree(uiSpecJson);
when(profileRegistryPlugin.getUISpecification()).thenReturn(uiSpecNode);

try {
registrationService.uploadFile(transactionId, fieldName, file);
Assert.fail("Expected SignUpException to be thrown");
} catch (SignUpException e) {
Assert.assertEquals(ErrorConstants.INVALID_FILE_TYPE, e.getErrorCode());
}
}

@Test
public void uploadFile_withFieldNameNotInUISpec_throwsInvalidField() throws JsonProcessingException {
String transactionId = "txn-123";
String fieldName = "unknownField";
byte[] pngBytes = new byte[]{
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A
};
MultipartFile file = new MockMultipartFile("file", "photo.png", "image/png", pngBytes);

RegistrationTransaction transaction = new RegistrationTransaction("user", Purpose.REGISTRATION);
when(cacheUtilService.getChallengeVerifiedTransaction(transactionId)).thenReturn(transaction);

String uiSpecJson = """
{
"schema": [
{
"id": "photo",
"controlType": "fileUpload",
"acceptedFileTypes": ["image/png", "image/jpeg"]
}
]
}
""";
JsonNode uiSpecNode = objectMapper.readTree(uiSpecJson);
when(profileRegistryPlugin.getUISpecification()).thenReturn(uiSpecNode);

SignUpException ex = Assert.assertThrows(SignUpException.class,
() -> registrationService.uploadFile(transactionId, fieldName, file));
Assert.assertEquals(ErrorConstants.INVALID_FIELD, ex.getErrorCode());
}


@Test
public void uploadFile_withMimeTypeNotInAllowedTypes_throwsInvalidFileType() throws JsonProcessingException {
String transactionId = "txn-123";
String fieldName = "photo";
// Valid PDF magic bytes (25504446 = %PDF)
byte[] pdfBytes = new byte[]{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34};
MultipartFile file = new MockMultipartFile("file", "document.pdf", "application/pdf", pdfBytes);

RegistrationTransaction transaction = new RegistrationTransaction("user", Purpose.REGISTRATION);
when(cacheUtilService.getChallengeVerifiedTransaction(transactionId)).thenReturn(transaction);

// UI spec with "photo" field that only accepts image/png and image/jpeg, NOT application/pdf
String uiSpecJson = """
{
"schema": [
{
"id": "photo",
"controlType": "fileUpload",
"acceptedFileTypes": ["image/png", "image/jpeg"]
}
]
}
""";
JsonNode uiSpecNode = objectMapper.readTree(uiSpecJson);
when(profileRegistryPlugin.getUISpecification()).thenReturn(uiSpecNode);

try {
registrationService.uploadFile(transactionId, fieldName, file);
Assert.fail("Expected SignUpException to be thrown");
} catch (SignUpException e) {
Assert.assertEquals(ErrorConstants.INVALID_FILE_TYPE, e.getErrorCode());
}
}

@Test(expected = InvalidTransactionException.class)
Expand Down