Skip to content

Fix predictable marker collision in EncodingPatternPreservation - #914

Open
Socialpranker wants to merge 1 commit into
ESAPI:developfrom
Socialpranker:fix-csscodec-marker-collision
Open

Fix predictable marker collision in EncodingPatternPreservation#914
Socialpranker wants to merge 1 commit into
ESAPI:developfrom
Socialpranker:fix-csscodec-marker-collision

Conversation

@Socialpranker

Copy link
Copy Markdown

Summary

EncodingPatternPreservation (used by CSSCodec to protect rgb(...) triplets from CSS-escaping during encodeForCSS) previously used a fixed, publicly-known default marker: this class's own simple name, "EncodingPatternPreservation".

captureAndReplaceMatches() temporarily swaps matched content for this marker; restoreOriginalContent() restores it afterwards using replaceFirst(marker, ...), matching the marker as plain literal text, in FIFO order.

Because the marker is a known constant, input that already contains that literal string ahead of real matched content causes replaceFirst to match the attacker-supplied text instead of the real placeholder. Every subsequent restoration in the same call is then shifted by one, silently corrupting the output — restored content ends up in the wrong position (or is left as a raw, unrestored marker string).

Minimal repro:

Pattern rgb = Pattern.compile("([rR][gG][bB])\\s*\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*\\)");
EncodingPatternPreservation epp = new EncodingPatternPreservation(rgb);

String input = "EncodingPatternPreservation color:rgb(1,2,3);";
String replaced = epp.captureAndReplaceMatches(input);
// replaced == "EncodingPatternPreservation color:EncodingPatternPreservation;"

String restored = epp.restoreOriginalContent(replaced);
// restored == "rgb(1,2,3) color:EncodingPatternPreservation;"   (WRONG — desynced)
// expected == "EncodingPatternPreservation color:rgb(1,2,3);"

I want to be precise about severity: I checked whether this is exploitable as an injection/XSS primitive and don't believe it is — the only regex currently fed through this class (CSSCodec's RGB-triplet pattern) can only ever capture digits, %, commas, whitespace and rgb(/), so a desynchronized restore can't smuggle unexpected characters through encodeForCSS(). This is an output-integrity bug (silently wrong/corrupted encoder output), not a bypass of the encoding itself.

Fix

Derive the default marker per-instance from a random UUID instead of a fixed literal, so it can't be predicted or pre-supplied in input. Hyphens are stripped from the UUID because a hyphen would itself be altered by an encoding pass (e.g. CSSCodec.encode() backslash-escapes non-immune characters) before restoreOriginalContent() runs, which would break the exact-match lookup — I hit this while writing the fix and confirmed it against the existing CSSCodecTest suite. The public setReplacementMarker() API is unchanged for callers who want to supply their own marker.

Test plan

  • Added testDefaultMarkerIsUnpredictablePerInstance — two instances never produce the same default marker.
  • Added testInputContainingLiteralClassNameDoesNotDesyncRestoration — regression test reproducing the exact scenario above.
  • Updated the two existing tests that asserted on the old fixed-marker string, to assert on the marker's presence/shape instead.
  • mvn -Dtest=EncodingPatternPreservationTest,CSSCodecTest test — 11/11 pass.
  • Full mvn test — pass, no other suite affected.

EncodingPatternPreservation.captureAndReplaceMatches() previously used a
fixed, publicly-known default marker (this class's simple name,
"EncodingPatternPreservation") to temporarily stand in for matched
content while the rest of the string is encoded.

restoreOriginalContent() restores captured content in FIFO order using
replaceFirst(marker, ...), matching the marker as plain literal text.
If the input already contains that literal string ahead of the real
matched content (e.g. CSSCodec.encode() on
"EncodingPatternPreservation background:rgb(1,2,3)"), replaceFirst
matches the attacker-supplied text instead of the real placeholder,
silently desynchronizing every subsequent restoration and corrupting
the encoded output.

Fix: derive the default marker per-instance from a random UUID (with
hyphens stripped, since a hyphen would itself be altered by an
encoding pass such as CSSCodec's before restoration), so it cannot be
predicted or embedded by input content. The public
setReplacementMarker() API is unchanged.

Added regression tests covering marker unpredictability and the
literal-marker-in-input desync scenario.
@Socialpranker

Copy link
Copy Markdown
Author

Filed the underlying bug as #915 with repro details, per the issue → PR workflow described in CONTRIBUTING-TO-ESAPI.txt.

@Socialpranker

Copy link
Copy Markdown
Author

Friendly ping — open since 14 July, with the underlying bug filed separately as #915 per CONTRIBUTING-TO-ESAPI.txt.

It replaces the fixed, publicly-known default marker in EncodingPatternPreservation (the class's own simple name) so that attacker-supplied content matching the marker can't collide with it during the captureAndReplaceMatches() / restoreOriginalContent() round trip in CSSCodec.

No rush — just noting it's ready when there's time. Happy to adjust the approach if you'd prefer a different fix for #915.

@Socialpranker

Copy link
Copy Markdown
Author

Following up on this and #915 — coming up on a month since filing, wanted to check it's not stuck somewhere.

@jeremiahjstacey

Copy link
Copy Markdown
Collaborator

Looking at the code submitted, I think that the solution provided solves the issue identified.
The replacementMarker value will be unique each time a new class is created. Tests reflect that intention and behavior.

The only issue I see and I'm not sure about is if this utility is ever used to write data to a location outside of the ESAPI JVM (EG: written to a flat-file between sessions) then the new instance will not be able to restore data from the previous instance since the markers are now guaranteed not to match.

I do think that what is provided does correct the problem as identified; however, I would like to get @xeno6696 to weigh in on the case above before merging. If there are any clients who do write data between sessions, then this addition would break backwards compatibility and potentially cause loss of data.

@xeno6696

xeno6696 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

I agree with @jeremiahjstacey that the fix here matches the identified problem, but I'm mulling over the corner case he brought up. I think it's worth calling out in the release notes, but the method setReplacementMarker(String); is exposed and if we want old behavior to be restored, the client can use that method to reinsert the broken functionality that is now fixed.

So yeah, worth calling out, but I wouldn't think to push this any deeper.

Created a "release-notes" label to flag any content that we want to ensure gets rolled into notes for the next release.

@xeno6696 xeno6696 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per agreement with @jeremiahjstacey, if you could just add the identified corner-case to the JavaDoc comment for the Codec I think release-notes should pick it up without additional fanfare.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the extra testing to boot, this was a high-quality first PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants