fix(a2a-server): enforce authentication and stop checkpoint path traversal - #28699
Conversation
…ersal
The A2A server's custom REST routes (/tasks, /executeCommand,
/listCommands, /tasks/:taskId/metadata) are registered directly on the
Express app and never go through the configured UserBuilder at all, so
they accept any request with no credentials whatsoever. Separately, the
A2A SDK's own request handler only checks user.isAuthenticated for the
authenticated-extended-card method, not for message/send and other
task-driving JSON-RPC methods, so the customUserBuilder wired into
createApp() was never actually enforced there either -- and the two
example credentials it checked against ('valid-token',
'admin:password') were hardcoded literals baked into public source.
Net effect: the agent card's declared bearerAuth/basicAuth
securitySchemes were not enforced anywhere, on a service documented to
be deployed as a public Cloud Run endpoint.
Add an authentication middleware applied ahead of every route (public
agent card excepted) that checks credentials against
CODER_AGENT_BEARER_TOKEN / CODER_AGENT_BASIC_USERNAME /
CODER_AGENT_BASIC_PASSWORD using a constant-time comparison, and fails
closed (rejects everything) when unconfigured rather than falling back
to a hardcoded default.
Separately, RestoreCommand builds the checkpoint file path from the
executeCommand request body's args via path.join(checkpointDir,
selectedFile) without validating selectedFile, so a name such as
"../../other-project/checkpoints/some-real-checkpoint.json" escapes
checkpointDir and reads (and, since matching checkpoints are echoed
back in the response, discloses the contents of) any other project's
checkpoint file on the host. Checkpoint filenames are always flat
(see generateCheckpointFileName), so reject any requested name that
isn't equal to its own path.basename.
|
📊 PR Size: size/L
|
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses critical security vulnerabilities in the A2A server. It enforces authentication across all custom REST routes and the JSON-RPC endpoint, replacing hardcoded credentials with environment-based configuration. Additionally, it secures the checkpoint restoration process by sanitizing input paths to prevent directory traversal attacks, ensuring that sensitive data cannot be disclosed. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces security enhancements to the a2a-server package, including directory traversal protection in the restore command and robust authentication middleware enforcing bearer token or basic authentication across endpoints. Constant-time comparisons are used to prevent timing attacks, and tests have been updated accordingly. Feedback highlights a potential timing leak in the basic authentication logic where the short-circuiting && operator could allow username enumeration; evaluating both username and password comparisons beforehand is recommended to ensure a true constant-time check.
| if ( | ||
| safeCompare(user, expectedUser) && | ||
| safeCompare(password, expectedPassword) | ||
| ) { | ||
| return { userName: 'basic-user', isAuthenticated: true }; | ||
| } |
There was a problem hiding this comment.
The use of the && operator here introduces a short-circuit timing leak. If the username comparison (safeCompare(user, expectedUser)) fails, the password comparison is skipped entirely. This allows an attacker to potentially enumerate valid usernames by measuring the response time. To ensure a true constant-time comparison, evaluate both comparisons before combining them.
const userMatch = safeCompare(user, expectedUser);
const passwordMatch = safeCompare(password, expectedPassword);
if (userMatch && passwordMatch) {
return { userName: 'basic-user', isAuthenticated: true };
}
Summary
The A2A server's custom REST routes (
/tasks,/executeCommand,/listCommands,/tasks/:taskId/metadata) are registered directly on the Express app increateApp()and never go through the configuredUserBuilderat all, so they accept requests with no credentials whatsoever. Separately,@a2a-js/sdk's ownDefaultRequestHandleronly checksuser.isAuthenticatedfor the authenticated-extended-card method, not formessage/sendand other task-driving JSON-RPC methods, socustomUserBuilderwas never actually enforced there either. On top of that, the two example credentials it checked against ('valid-token','admin:password') are hardcoded literals in public source. Net effect: the agent card's declaredbearerAuth/basicAuthsecuritySchemeswere not enforced anywhere, on a service the docs (docs/core/remote-agents.md) describe deploying as a public Cloud Run endpoint.Verified locally: a built
a2a-serveracceptedPOST /tasks,POST /executeCommand,POST /(message/send), andGET /listCommandswith zeroAuthorizationheader at all.Separately,
RestoreCommandbuilds the checkpoint file path from theexecuteCommandrequest body'sargsviapath.join(checkpointDir, selectedFile)without validatingselectedFile, so a name such as../../other-project/checkpoints/some-real-checkpoint.jsonescapescheckpointDir. Since matching checkpoint content is echoed back in the response, this discloses the contents of any other project's checkpoint file on the host (which routinely contains full conversation/tool-call history). Verified with a crafted sibling checkpoint file: the traversal read its content back through/executeCommandwith no authentication.Fix
CODER_AGENT_BEARER_TOKEN/CODER_AGENT_BASIC_USERNAME/CODER_AGENT_BASIC_PASSWORDusing a constant-time comparison, and fails closed (rejects everything) when unconfigured rather than falling back to a hardcoded default.RestoreCommand: reject any requested checkpoint name that isn't equal to its ownpath.basename(checkpoint filenames are always flat, seegenerateCheckpointFileName), before it ever reaches the filesystem.app.test.ts/endpoints.test.tsto authenticate via the new env-configured bearer token, and added regression tests for: no-credentials rejection on every custom route and the JSON-RPC endpoint, rejection of the old hardcoded-style credential, and traversal rejection inrestore.test.ts.Test plan
npx vitest run --root packages/a2a-server— 154/154 passingnpm run typecheck --workspace=@google/gemini-cli-a2a-servernpx eslinton all changed files — clean/tasks,/executeCommand,/listCommands, andPOST /(message/send) all return401with no credentials post-fix (previously all succeeded); confirmed a valid configured bearer token still authenticates correctly; confirmed the checkpoint traversal PoC is rejected with "Invalid checkpoint name" both with and without valid credentials.