From 181d3edab4ff1aa49d90c02a589760a6b4133c86 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:52:26 +0800 Subject: [PATCH 1/2] fix: align Jackson 2 security patches and expose upstream build blockers --- .github/workflows/ci.yml | 36 +++++------- .mvn/ci-settings.xml | 11 ++++ pom.xml | 18 ++++-- .../JacksonJwtCompatibilityTest.java | 56 +++++++++++++++++++ 4 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 .mvn/ci-settings.xml create mode 100644 src/test/java/org/springframework/security/boot/jwt/authentication/JacksonJwtCompatibilityTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9225bd..40cfc82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,33 +45,27 @@ jobs: tar -xzf /tmp/apache-maven-4-bin.tar.gz -C "$HOME/apache-maven-4" --strip-components=1 echo "$HOME/apache-maven-4/bin" >> "$GITHUB_PATH" - - name: Install Easy4J snapshot dependencies - env: - EASY4J_BRANCH: ${{ github.base_ref || github.ref_name }} - EASY4J_DEPENDENCIES: "jwt-issuer spring-security-extension" + - name: Build pinned public Easy4J snapshot sources run: | set -euo pipefail dependency_root="${RUNNER_TEMP}/easy4j-dependencies" mkdir -p "${dependency_root}" - for dependency in ${EASY4J_DEPENDENCIES}; do - git clone --depth 1 --branch "${EASY4J_BRANCH}" \ - "https://github.com/easy-4-java/${dependency}.git" \ - "${dependency_root}/${dependency}" - for attempt in 1 2 3; do - if mvn -B --no-transfer-progress \ - -f "${dependency_root}/${dependency}/pom.xml" \ - -Dmaven.test.skip=true -Djacoco.skip=true -Dgpg.skip=true install; then - break - fi - if [[ "${attempt}" -eq 3 ]]; then - exit 1 - fi - sleep "$((attempt * 10))" - done - done + # Fixed public revisions: changing a sibling main must not silently + # change the snapshot classes tested by this repository. + while read -r dependency revision; do + git init "${dependency_root}/${dependency}" + git -C "${dependency_root}/${dependency}" remote add origin "https://github.com/easy-4-java/${dependency}.git" + git -C "${dependency_root}/${dependency}" fetch --depth 1 origin "${revision}" + git -C "${dependency_root}/${dependency}" checkout --detach FETCH_HEAD + test "$(git -C "${dependency_root}/${dependency}" rev-parse HEAD)" = "${revision}" + mvn -B --no-transfer-progress -s .mvn/ci-settings.xml -f "${dependency_root}/${dependency}/pom.xml" -Dgpg.skip=true clean install + done <<'SOURCES' + jwt-issuer fe29ffd8471eff70d2117b73e4cb2459e5ec4a2f + spring-security-extension 385e4ae3b16f369c94a9068afcb27fca47494767 + SOURCES - name: Build and verify with JaCoCo coverage gate - run: mvn -B --no-transfer-progress clean verify + run: mvn -B --no-transfer-progress -s .mvn/ci-settings.xml clean verify - name: Upload JaCoCo coverage report if: always() diff --git a/.mvn/ci-settings.xml b/.mvn/ci-settings.xml new file mode 100644 index 0000000..f03aef6 --- /dev/null +++ b/.mvn/ci-settings.xml @@ -0,0 +1,11 @@ + + + + + + central-public + * + https://repo.maven.apache.org/maven2 + + + diff --git a/pom.xml b/pom.xml index 35250b7..527d941 100644 --- a/pom.xml +++ b/pom.xml @@ -46,7 +46,7 @@ 3.20.0 2.0.x.20260630-SNAPSHOT - 2.17.2 + 2.18.12 4.0.1 6.1.0 5.18.0 @@ -98,11 +98,13 @@ ${spring-framework.version} provided - + - com.fasterxml.jackson.core - jackson-databind + com.fasterxml.jackson + jackson-bom ${jackson.version} + pom + import @@ -222,6 +224,14 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + true + + diff --git a/src/test/java/org/springframework/security/boot/jwt/authentication/JacksonJwtCompatibilityTest.java b/src/test/java/org/springframework/security/boot/jwt/authentication/JacksonJwtCompatibilityTest.java new file mode 100644 index 0000000..0477435 --- /dev/null +++ b/src/test/java/org/springframework/security/boot/jwt/authentication/JacksonJwtCompatibilityTest.java @@ -0,0 +1,56 @@ +package org.springframework.security.boot.jwt.authentication; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.util.Base64; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import static org.junit.jupiter.api.Assertions.*; + +class JacksonJwtCompatibilityTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void signedJwtJsonPreservesUnicodeAndClaimTypes() throws Exception { + String header = encode(mapper.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT"))); + String payload = encode(mapper.writeValueAsBytes(Map.of("sub", "用户", "roles", new String[]{"reader"}, "exp", 2000000000L))); + String input = header + "." + payload; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(new byte[32], "HmacSHA256")); + String jwt = input + "." + encode(mac.doFinal(input.getBytes(java.nio.charset.StandardCharsets.US_ASCII))); + String[] parts = jwt.split("\\."); + assertEquals(3, parts.length); + assertArrayEquals(mac.doFinal(input.getBytes(java.nio.charset.StandardCharsets.US_ASCII)), Base64.getUrlDecoder().decode(parts[2])); + JsonNode claims = mapper.readTree(Base64.getUrlDecoder().decode(parts[1])); + assertEquals("用户", claims.get("sub").asText()); + assertEquals("reader", claims.get("roles").get(0).asText()); + assertEquals(2000000000L, claims.get("exp").longValue()); + JwtAuthenticationToken token = new JwtAuthenticationToken(claims.get("sub").asText(), jwt); + assertFalse(token.isAuthenticated()); + assertThrows(IllegalArgumentException.class, () -> token.setAuthenticated(true)); + token.eraseCredentials(); + assertNull(token.getCredentials()); + } + + @Test + void rejectsMalformedClaimsAndDoesNotEnablePolymorphicTyping() throws Exception { + assertThrows(JsonProcessingException.class, () -> mapper.readTree("{\"sub\": [}")); + Object value = mapper.readValue("{\"@class\":\"java.lang.Runtime\",\"sub\":\"alice\"}", Object.class); + assertInstanceOf(Map.class, value); + assertEquals("alice", ((Map) value).get("sub")); + } + + @Test + void rejectsOverlyNestedUntrustedClaims() { + String json = "[".repeat(1001) + "0" + "]".repeat(1001); + assertThrows(com.fasterxml.jackson.core.exc.StreamConstraintsException.class, + () -> mapper.readTree(json)); + } + + private static String encode(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} From 02ada334f5f6c9f8f8e1450a575bf40f2e843630 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:13:01 +0800 Subject: [PATCH 2/2] fix(ci): upload reports only from the current verify invocation --- .github/scripts/run_verify.py | 55 ++++++++++++++++++++++++++ .github/scripts/test_run_verify.py | 62 ++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 15 ++++++-- 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/run_verify.py create mode 100644 .github/scripts/test_run_verify.py diff --git a/.github/scripts/run_verify.py b/.github/scripts/run_verify.py new file mode 100644 index 0000000..7cfecb7 --- /dev/null +++ b/.github/scripts/run_verify.py @@ -0,0 +1,55 @@ +"""Run verify after removing checkout artifacts; expose only this invocation's reports.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess + + +def clean_target(root): + target = root / "target" + if target.is_symlink(): + target.unlink() + elif target.exists(): + shutil.rmtree(target) + + +def run_verify(root, command): + clean_target(root) + # A failed Maven invocation must retain its exit status even if it wrote reports. + try: + status = subprocess.run(command, cwd=root, check=False).returncode + except OSError as error: + print(error) + status = 127 + reports = { + "surefire": any((root / "target/surefire-reports").glob("TEST-*.xml")), + "jacoco": (root / "target/site/jacoco/jacoco.xml").is_file(), + } + output = os.environ.get("GITHUB_OUTPUT") + if output: + with open(output, "a", encoding="utf-8") as stream: + for name, present in reports.items(): + stream.write(f"{name}={str(present).lower()}\n") + for name, present in reports.items(): + if present: + directory = root / ("target/surefire-reports" if name == "surefire" else "target/site/jacoco") + (directory / "ci-origin.json").write_text(json.dumps({ + "sha": os.environ.get("GITHUB_SHA"), + "run_id": os.environ.get("GITHUB_RUN_ID"), + "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"), + "verify_exit_code": status, + }) + "\n", encoding="utf-8") + return status if status >= 0 else 128 - status + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--clean-only", action="store_true") + args = parser.parse_args() + root = Path.cwd() + if args.clean_only: + clean_target(root) + else: + raise SystemExit(run_verify(root, ["mvn", "-B", "--no-transfer-progress", "-s", ".mvn/ci-settings.xml", "clean", "verify"])) diff --git a/.github/scripts/test_run_verify.py b/.github/scripts/test_run_verify.py new file mode 100644 index 0000000..2f08c31 --- /dev/null +++ b/.github/scripts/test_run_verify.py @@ -0,0 +1,62 @@ +"""Synthetic report-provenance tests; these do not stand in for Maven tests.""" +import importlib.util +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("run_verify", Path(__file__).with_name("run_verify.py")) +verify = importlib.util.module_from_spec(spec) +spec.loader.exec_module(verify) + + +class ReportProvenanceTest(unittest.TestCase): + def exercise(self, exit_code, generate): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for path in ["target/surefire-reports/TEST-old.xml", "target/site/jacoco/jacoco.xml"]: + old = root / path + old.parent.mkdir(parents=True, exist_ok=True) + old.write_text("old Mac / Jackson 2.17 report") + output = root / "outputs" + command = [sys.executable, "-c", ( + "from pathlib import Path; " + "assert not Path('target').exists(); " + + ("p=Path('target/surefire-reports/TEST-current.xml'); p.parent.mkdir(parents=True); p.write_text(''); " + "p=Path('target/site/jacoco/jacoco.xml'); p.parent.mkdir(parents=True); p.write_text(''); " if generate else "") + + f"raise SystemExit({exit_code})" + )] + with patch.dict(os.environ, {"GITHUB_OUTPUT": str(output), "GITHUB_SHA": "current-sha"}): + self.assertEqual(exit_code, verify.run_verify(root, command)) + expected = str(generate).lower() + self.assertEqual(f"surefire={expected}\njacoco={expected}\n", output.read_text()) + self.assertFalse((root / 'target/surefire-reports/TEST-old.xml').exists()) + for path in (root / 'target').rglob('*'): + if path.is_file(): + self.assertNotIn('Jackson 2.17', path.read_text()) + if generate: + self.assertIn('current-sha', (root / 'target/surefire-reports/ci-origin.json').read_text()) + + def test_success_uploads_only_current_reports(self): + self.exercise(0, True) + + def test_failure_retains_failure_and_current_reports(self): + self.exercise(1, True) + + def test_failure_before_tests_does_not_upload_old_reports(self): + self.exercise(2, False) + + def test_skipped_project_build_has_no_reports_or_output(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / 'target/surefire-reports').mkdir(parents=True) + (root / 'target/surefire-reports/TEST-old.xml').write_text('old Mac / Jackson 2.17') + verify.clean_target(root) + self.assertFalse((root / 'target').exists()) + self.assertFalse((root / 'outputs').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40cfc82..6d42389 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,12 @@ jobs: - name: Checkout source uses: actions/checkout@v7 + - name: Remove checkout build artifacts on runner + run: python3 .github/scripts/run_verify.py --clean-only + + - name: Test report provenance helper + run: python3 .github/scripts/test_run_verify.py + - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -65,20 +71,23 @@ jobs: SOURCES - name: Build and verify with JaCoCo coverage gate - run: mvn -B --no-transfer-progress -s .mvn/ci-settings.xml clean verify + id: project_verify + run: python3 .github/scripts/run_verify.py - name: Upload JaCoCo coverage report - if: always() + if: ${{ always() && steps.project_verify.outputs.jacoco == 'true' }} uses: actions/upload-artifact@v7 with: name: jacoco-coverage path: target/site/jacoco + if-no-files-found: error retention-days: 14 - name: Upload surefire reports - if: always() + if: ${{ always() && steps.project_verify.outputs.surefire == 'true' }} uses: actions/upload-artifact@v7 with: name: surefire-reports path: target/surefire-reports + if-no-files-found: error retention-days: 14