RecordUpdateValueIgnoreView5966Test.java

package tools.jackson.databind.records;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.annotation.OptBoolean;

import tools.jackson.databind.InjectableValues;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.*;

/**
 * [databind#5966]: Record updateValue() bypasses @JsonIgnore and @JsonView checks.
 */
public class RecordUpdateValueIgnoreView5966Test extends DatabindTestUtil
{
    // --- View classes ---
    static class PublicView {}
    static class AdminView extends PublicView {}

    // --- Record model --- (must be public for Jackson reflection)

    public record SecretRecord(
        String name,
        @JsonIgnore String secret
    ) {}

    @JsonIgnoreProperties({"password"})
    public record IgnoredPropsRecord(
        String username,
        String password
    ) {}

    public record ViewRecord(
        @JsonView(PublicView.class) String publicField,
        @JsonView(AdminView.class) String adminField
    ) {}

    public record InjectRecord(
        String name,
        @JacksonInject(value = "injected-key", useInput = OptBoolean.FALSE) String injected
    ) {}

    // --- Tests ---

    private final ObjectMapper MAPPER = newJsonMapper();

    /**
     * Normal (non-update) deserialization of a Record with
     * @JsonIgnore must reject the ignored field.
     */
    @Test
    public void testJsonIgnore_normalDeser_negativeControl() throws Exception {
        // Normal readValue should honour @JsonIgnore
        SecretRecord result = MAPPER.readValue("{\"name\":\"alice\",\"secret\":\"s3cr3t\"}", SecretRecord.class);
        // The exact behaviour depends on jackson version; the key point is that
        // updateValue is the vulnerable path. We verify updateValue separately.
        // Normal deser may accept or reject; the security concern is updateValue.
        assertNotNull(result.name());
    }

    /**
     * ObjectMapper.updateValue() for a Record with @JsonIgnore.
     */
    @Test
    public void test5966_updateValueBypassesJsonIgnore() throws Exception {
        SecretRecord original = new SecretRecord("alice", "original-secret");
        String maliciousJson = "{\"name\":\"alice\",\"secret\":\"HACKED\"}";

        SecretRecord updated = MAPPER.updateValue(original, MAPPER.readTree(maliciousJson));

        assertEquals("alice", updated.name());
        // @JsonIgnore on "secret" must prevent the attacker JSON value from taking effect.
        assertNotEquals("HACKED", updated.secret(),
            "[databind#5966] VULNERABLE: updateValue() bypassed @JsonIgnore on Record component. " +
            "secret was overwritten to: " + updated.secret());
        // Original value must be retained (not nulled out).
        assertEquals("original-secret", updated.secret(),
            "[databind#5966] secret should retain original value but was: " + updated.secret());
    }

    /**
     * Verify that @JsonIgnoreProperties prevents the JSON value from being
     * assigned. Data-integrity assertion: original "original-pw" must be retained.
     */
    @Test
    public void test5966_updateValueBypassesJsonIgnoreProperties() throws Exception {
        IgnoredPropsRecord original = new IgnoredPropsRecord("alice", "original-pw");
        String maliciousJson = "{\"username\":\"alice\",\"password\":\"HACKED\"}";

        IgnoredPropsRecord updated = MAPPER.updateValue(original, MAPPER.readTree(maliciousJson));

        assertEquals("alice", updated.username());
        // @JsonIgnoreProperties({"password"}) must block the attacker value.
        assertNotEquals("HACKED", updated.password(),
            "[databind#5966] VULNERABLE: updateValue() bypassed @JsonIgnoreProperties on Record. " +
            "password was overwritten to: " + updated.password());
        // Original value must be retained (not nulled out).
        assertEquals("original-pw", updated.password(),
            "[databind#5966] password should retain original value but was: " + updated.password());
    }

    /**
     * ObjectMapper.updateValue() with @JsonView ��� admin-only field
     * must not be updated when active view is PublicView.
     */
    @Test
    public void test5966_updateValueBypassesJsonView() throws Exception {
        ViewRecord original = new ViewRecord("public-val", "admin-original");
        String maliciousJson = "{\"publicField\":\"new-public\",\"adminField\":\"HACKED-ADMIN\"}";

        ViewRecord updated = MAPPER
            .readerWithView(PublicView.class)
            .withValueToUpdate(original)
            .readValue(maliciousJson);

        // publicField has @JsonView(PublicView.class): visible in PublicView ��� updated
        assertEquals("new-public", updated.publicField());
        // adminField has @JsonView(AdminView.class); with PublicView active it must not be updated
        assertNotEquals("HACKED-ADMIN", updated.adminField(),
            "[databind#5966] updateValue() bypassed @JsonView on Record component. " +
            "adminField was overwritten to: " + updated.adminField());
        // The original adminField value must be preserved (pre-populated in Step 1)
        assertEquals("admin-original", updated.adminField(),
            "[databind#5966] adminField should remain 'admin-original' but was: " + updated.adminField());
    }

    /**
     * ObjectMapper.updateValue() with @JacksonInject(useInput=FALSE) on a Record
     * component: JSON value must be skipped, injected value must take effect.
     */
    @Test
    public void test5966_updateValueRespectsInjectionOnly() throws Exception {
        ObjectMapper mapper = jsonMapperBuilder()
            .injectableValues(new InjectableValues.Std().addValue("injected-key", "INJECTED"))
            .build();
        InjectRecord original = new InjectRecord("alice", "original-injected");
        String maliciousJson = "{\"name\":\"alice\",\"injected\":\"HACKED\"}";

        InjectRecord updated = mapper.updateValue(original, mapper.readTree(maliciousJson));

        assertEquals("alice", updated.name());
        assertNotEquals("HACKED", updated.injected(),
            "[databind#5966] updateValue() bypassed isInjectionOnly() on Record component. " +
            "injected was overwritten to: " + updated.injected());
        assertEquals("INJECTED", updated.injected(),
            "[databind#5966] injected component should hold the injected value but was: " + updated.injected());
    }

    /**
     * Empty-JSON updateValue() of a Record with @JsonIgnore: pre-populate must
     * retain the source record's value for ignored components when no JSON
     * properties are provided to override.
     */
    @Test
    public void test5966_updateValueEmptyJsonRetainsIgnored() throws Exception {
        SecretRecord original = new SecretRecord("alice", "original-secret");

        SecretRecord updated = MAPPER.updateValue(original, MAPPER.readTree("{}"));

        assertEquals("alice", updated.name());
        assertEquals("original-secret", updated.secret(),
            "[databind#5966] empty-JSON update should retain original secret but was: " + updated.secret());
    }
}