Authenticate CLI API requests - #13
Conversation
The hosted service now scopes Projects by bearer credentials. Require one for every network command so pushes, status polls, compilation requests, and artifact downloads share the same user boundary without persisting the secret locally. Surface the validated server authentication problem as one stable recovery contract for agents.
Technical reviewA PR that introduces a bearer credential is worth attacking rather than reading, so I went looking for ways the token could end up somewhere it should not. It cannot, and the reasons are worth writing down because most of them are deliberate rather than incidental.
The one that would have been serious
const endpoint = new URL(metadata.path, apiUrl);
That is closed properly, in typeof value !== "string" ||
!value.startsWith("/") || // absolute URLs
value.startsWith("//") || // protocol-relative
value.includes("\\") // backslash tricksconst parsed = new URL(value, "https://firstdraft.invalid");
parsed.origin === "https://firstdraft.invalid" &&
parsed.pathname === value && // no encoding or traversal
parsed.search === "" && parsed.hash === "" &&
(value === scope || value.startsWith(`${scope}/`))Rejecting the absolute form, the protocol-relative form, and anything whose parsed pathname differs from the input covers the escapes. Requiring the value to sit inside Where else the token could goThe destination origin is constrained before any of this, in const loopbackHttp = url.protocol === "http:" &&
(url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]");
if ((url.protocol !== "https:" && !loopbackHttp) || url.username !== "" || url.password !== "" || ...)HTTPS anywhere, plaintext HTTP only to the three loopback names, and embedded credentials rejected so Behaviour I checked rather than assumedThe second call passed Returning The remaining claims holdNo reference to a token anywhere in
No findings |
Lesson: never send a credential to a URL somebody else choseHere is a line that appears in this PR, and something very like it appears in most API clients: const endpoint = new URL(metadata.path, apiUrl);
Now the part that surprises people. If the first argument is an absolute URL, the base is ignored completely. new URL("/v1/artifacts/42", "https://api.example.com")
// => https://api.example.com/v1/artifacts/42 as intended
new URL("https://attacker.example/x", "https://api.example.com")
// => https://attacker.example/x base discardedThis is correct behaviour, specified, and identical in every language's URL resolver. It is also a credential-exfiltration primitive, because the next line attaches your bearer token and calls it. The general ruleA URL from a response is untrusted input, exactly like a form field. We internalised that user input needs validation. Server responses feel different, because we think of "the server" as ours. But the value only has to become attacker-controlled once, through a compromised dependency, a misconfigured proxy, a hostile registry, or a bug that lets one tenant write another tenant's record, and the client will faithfully carry credentials wherever it is pointed. Three variants worth recognising: Absolute URL where a path was expected, as above. Protocol-relative URLs. Redirects. Even a valid relative path can respond What this looks like in Railsresponse = HTTParty.get(payload["next_url"], headers: { "Authorization" => "Bearer #{token}" })Same shape. The variants you will actually meet: redirect_to params[:return_to] # open redirect; use redirect_to ... allow_other_host: false
Net::HTTP.get(URI(user.avatar_url)) # SSRF; can reach 169.254.169.254 and your cloud metadata
send_webhook(to: integration.callback_url, headers: auth_headers) # tenant-supplied destinationRails 7 made The defence that actually worksValidate the shape, not the badness. Blocklists lose. The check in this PR is worth reading as a template, because it establishes four separate things: !value.startsWith("/") || value.startsWith("//") || value.includes("\\")then re-parses and confirms the result did not escape: parsed.origin === "https://firstdraft.invalid" &&
parsed.pathname === value &&
parsed.search === "" && parsed.hash === ""and finally requires the path to sit inside the specific resource it belongs to. Parse it, then check that parsing did not change it. The Ruby equivalent for the common case: def safe_callback_uri(candidate)
uri = URI.parse(candidate)
raise ArgumentError unless uri.is_a?(URI::HTTPS)
raise ArgumentError unless ALLOWED_HOSTS.include?(uri.host)
raise ArgumentError unless uri.userinfo.nil?
uri
endAn allowlist of hosts, a required scheme, and a rejection of embedded credentials. Three lines, and it removes an entire category. The habitWhen you attach a credential to an outbound request, ask one question: who decided this URL? If the answer is anything other than "my own configuration, validated at load," the credential should not go until the destination has been checked. And if the destination is genuinely dynamic, the right move is usually a scoped token that is worthless elsewhere, rather than a better validator. |
Summary
FIRSTDRAFT_API_TOKENforplan push,plan status, andplan compileauthentication_requiredproblem through one stable CLI error.firstdraft, command output, and local-only commandsContract
A missing or whitespace-only token stops before local input or network access. Invalid and revoked credentials are validated by First Draft; only its validated
401problem with codeauthentication_requiredis promoted to the stable authentication error. Other unvalidated responses retain the CLI's conservative protocol and mutation-recovery handling.Verification
npm run checkgit diff --check