Skip to content

fix(oss-licenses-plugin): sanitize newlines in Dependency.name to prevent metadata injection - #461

Open
timothyfroehlich wants to merge 1 commit into
mainfrom
froeht/fix-newline-injection
Open

timothyfroehlich wants to merge 1 commit into
mainfrom
froeht/fix-newline-injection

Conversation

@timothyfroehlich

Copy link
Copy Markdown
Member

Strip CR/LF characters and trim whitespace in Dependency constructor, and make key/name final to prevent Groovy setter bypass.

@timothyfroehlich
timothyfroehlich force-pushed the froeht/fix-newline-injection branch from 2172a66 to fe25d93 Compare September 9, 2026 19:21
@timothyfroehlich
timothyfroehlich force-pushed the froeht/fix-newline-injection branch from fe25d93 to 88b5b04 Compare September 9, 2026 19:57
String expected =
"0:8 Dependency 1 0:120 Forged Entry"
+ LINE_BREAK
+ "9:8 Dependency 2 Spoofed"

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.

Consider calculate the second offset dynamically to avoid issues on different OS.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done. Offsets are derived from the license byte lengths plus the separator width, so the test holds under both LF and CRLF.

this.key = Objects.requireNonNull(key, "key cannot be null")
this.name = Objects.requireNonNull(name, "name cannot be null")
.replaceAll(/\R+/, ' ')
.strip()

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.

Consider defaulting to key if name becomes empty after sanitization.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done: this.name = sanitizedName.isEmpty() ? this.key : sanitizedName.

Worth flagging that this fallback is defensive only. All four new Dependency(...) call sites already guarantee a non-blank name: two pass the key as the name, and the POM path falls back to licenseKey at addLicensesFromPom:315-317. testAddLicensesFromPom_blankNameIsAttributedByCoordinate pins the reachable path.

Dependency(String key, String name) {
this.key = key
this.name = name
this.key = Objects.requireNonNull(key, "key cannot be null")

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.

Key should be sanitized as well?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done. Both fields now run through a shared sanitize helper in the constructor.

The key mattered for a second reason. processLicenseEntry deduplicates on it, and the raw key was feeding embeddedLicenses while the sanitized key fed licensesMap. That made "foo\nbar" and "foo bar" two dedup entries but one map entry. Both now use dependency.key.

@timothyfroehlich
timothyfroehlich force-pushed the froeht/fix-newline-injection branch 3 times, most recently from 37bc025 to 35b6f0c Compare September 14, 2026 12:10
InputStream stream,
long offset,
int length) {
if (offset < 0 || length < 0) {

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.

This is before stream.withCloseable, throwing IllegalArgumentException leaves stream open

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I wasn't ready for re-review yet, was fixing up some things first.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, fixed. The guard now sits inside withCloseable but outside the inner try. That is the only arrangement that closes the stream and still lets the IllegalArgumentException propagate unwrapped, since inside the try the existing catch-all would rewrap it as RuntimeException(FAIL_READING_LICENSES_ERROR, e).

testGetBytesFromInputStream_invalidBoundsClosesStream asserts verify(inputStream).close() on the throwing path.

new LicensesTask.Dependency("test:baz\nkey", "\r\n \n\r"), licenseC);
licensesTask.writeMetadata();

int secondOffset = licenseA.length + LINE_BREAK.length();

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.

Use LINE_BREAK.getBytes(UTF_8).length keeps all offset arithmetic strictly in bytes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, done. LINE_BREAK.length() is a char count being summed with licenseA.length, a byte count. They agree only because separators are ASCII. Now hoisted to int lineBreakBytes = LINE_BREAK.getBytes(UTF_8).length.

A dependency author could inject line breaks into a library display name, via
either the Maven POM <name> element or a key in an AAR's
third_party_licenses.json, and forge extra records in the newline-delimited
res/raw/third_party_license_metadata file. Each injected break yielded a
fully attacker-controlled attribution entry in the consuming app's license
menu.

Sanitize both key and name in the Dependency constructor, the only point at
which an instance can be created, collapsing every Unicode line break to a
single space. Make the fields final so Groovy's generated setters and map
constructor cannot write past the constructor. Reject a blank key, and log
and skip such records in processLicenseEntry rather than breaking a
consumer's build over one malformed third-party entry.

Also harden getBytesFromInputStream: reject a negative offset or length, and
close the stream on every path by way of withCloseable.

BUG=557266592

CONV=b20a8ae9-55d6-4655-87f8-edba5da5028b

TAG=agy
@timothyfroehlich
timothyfroehlich force-pushed the froeht/fix-newline-injection branch from 35b6f0c to c051fc1 Compare September 14, 2026 17:55
@timothyfroehlich

Copy link
Copy Markdown
Member Author

Pushed c051fc1, ready for another look.

Changes since your review:

  • getBytesFromInputStream bounds check moved inside withCloseable, so the stream closes on the invalid-argument path.
  • Metadata test offset arithmetic is now byte-based.
  • New POM fixtures covering the multi-license branch, a blank display name, and the &#13; and &#8232; encodings.
  • GroovyDoc on Dependency, sanitize, buildLicensesMetadata, and getBytesFromInputStream.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants