diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000000..3047ab29349 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,19 @@ +name: "cli/cli CodeQL config" + +# This config extends the default `security-and-quality` suite with the +# custom queries in `.github/codeql/queries/`. The custom queries enforce +# project-specific invariants that are not covered by the stock packs: +# +# - unsanitized-response-to-terminal.ql: HTTP response content that +# reaches a terminal writer (`os.Stdout` / `os.Stderr` / +# `iostreams.IOStreams.Out` / `ErrOut`) other than `ContentOut` +# without being sanitized. Writing to `ContentOut`, calling +# `iostreams.Untrusted.String`, wrapping with `asciisanitizer`, or +# decoding as structured JSON are accepted, so untrusted response +# content is sanitized before it can reach a terminal. +# +# This config is only meaningful for the Go matrix entry; the Actions +# matrix entry ignores it. +queries: + - uses: security-and-quality + - uses: ./.github/codeql/queries diff --git a/.github/codeql/codeql-pack.lock.yml b/.github/codeql/codeql-pack.lock.yml new file mode 100644 index 00000000000..357ee5dab5c --- /dev/null +++ b/.github/codeql/codeql-pack.lock.yml @@ -0,0 +1,24 @@ +--- +lockVersion: 1.0.0 +dependencies: + codeql/concepts: + version: 0.0.24 + codeql/controlflow: + version: 2.0.34 + codeql/dataflow: + version: 2.1.6 + codeql/go-all: + version: 7.1.1 + codeql/mad: + version: 1.0.50 + codeql/ssa: + version: 2.0.26 + codeql/threat-models: + version: 1.0.50 + codeql/tutorial: + version: 1.0.50 + codeql/typetracking: + version: 2.0.34 + codeql/util: + version: 2.0.37 +compiled: false diff --git a/.github/codeql/qlpack.yml b/.github/codeql/qlpack.yml new file mode 100644 index 00000000000..cdcdfcc7d69 --- /dev/null +++ b/.github/codeql/qlpack.yml @@ -0,0 +1,9 @@ +name: cli/cli-custom-security +version: 0.0.1 +library: false +extractor: go +tests: tests +dependencies: + codeql/go-all: ^7.1.1 +default-suite: + - queries: queries diff --git a/.github/codeql/queries/ImmutableSafeURLConstruction.ql b/.github/codeql/queries/ImmutableSafeURLConstruction.ql new file mode 100644 index 00000000000..39daa77f20e --- /dev/null +++ b/.github/codeql/queries/ImmutableSafeURLConstruction.ql @@ -0,0 +1,58 @@ +/** + * @name ImmutableSafeURL built from a hand-assembled string + * @description Flags a call to safeurl.NewImmutableSafeURL whose argument is a locally assembled + * string, that is a value tainted by fmt.Sprintf, fmt.Sprint, fmt.Sprintln or a string + * concatenation. NewImmutableSafeURL renders its argument verbatim, skipping the + * percent-encoding and traversal check that JoinPath applies, so it must only receive an + * already formed, trusted URL such as a server returned field or a pagination link. A + * hand-built path reaching it is a way to route around safeurl and must instead be built + * with safeurl.JoinPath. This query is a convention guard, it cannot and does not verify + * the trustedness of URLs read from struct fields or returned by API calls. + * @kind problem + * @problem.severity warning + * @precision high + * @id cli-cli/immutable-safeurl-construction + * @tags security + * correctness + * maintainability + */ + +import go + +/** + * Holds when `node` is the URL argument of a call to safeurl.NewImmutableSafeURL, the escape hatch + * that renders its argument verbatim without percent-encoding or a traversal check. + */ +predicate isImmutableSafeURLArgument(DataFlow::Node node) { + exists(Function f, DataFlow::CallNode call | + f.hasQualifiedName("github.com/cli/cli/v2/internal/safeurl", "NewImmutableSafeURL") and + call = f.getACall() and + node = call.getArgument(0) + ) +} + +/** + * Holds when `node` is a locally assembled string: the result of fmt.Sprintf, fmt.Sprint or + * fmt.Sprintln, or a string concatenation expression. These are the shapes that build a URL by hand + * rather than reading an already formed value, so they must not reach NewImmutableSafeURL. + */ +predicate isHandAssembledString(DataFlow::Node node) { + exists(Function f | + f.hasQualifiedName("fmt", ["Sprintf", "Sprint", "Sprintln"]) and + node = f.getACall() + ) + or + exists(AddExpr e | + e.getType() instanceof StringType and + node = DataFlow::exprNode(e) + ) +} + +from DataFlow::Node source, DataFlow::Node sink +where + isImmutableSafeURLArgument(sink) and + isHandAssembledString(source) and + TaintTracking::localTaint(source, sink) +select sink, + "This ImmutableSafeURL is built from a hand-assembled string ($@); build the path with safeurl.JoinPath so its components are escaped and traversal-checked.", + source, "assembled here" diff --git a/.github/codeql/queries/SafeURLPathConstruction.ql b/.github/codeql/queries/SafeURLPathConstruction.ql new file mode 100644 index 00000000000..3697b9162a9 --- /dev/null +++ b/.github/codeql/queries/SafeURLPathConstruction.ql @@ -0,0 +1,73 @@ +/** + * @name HTTP request URL not built with safeurl.SafeURL + * @description Flags any HTTP request, a REST API call being the common case, whose URL argument is + * not literally a call to (safeurl.SafeURL).String. The argument expression itself must + * be a SafeURL.String call; any other form, such as a string literal, string + * concatenation, or fmt.Sprintf, is reported. This keeps every hand built URL routed + * through safeurl so its variable path components are percent-encoded. + * @kind problem + * @problem.severity warning + * @precision high + * @id cli-cli/safeurl-path-construction + * @tags security + * correctness + * maintainability + */ + +import go + +/** + * Holds when `node` is the URL argument of an HTTP request, a REST API call being the common case. + * + * Covered entry points: + * - (github.com/cli/cli/v2/api.Client).REST and .RESTWithNext, where the path is argument 2. + * - net/http.NewRequest, where the URL is argument 1. + * - net/http.NewRequestWithContext, where the URL is argument 2. + * - (net/http.Client).Get, .Head, .Post and .PostForm, where the URL is argument 0. + */ +predicate isHttpUrlArgument(DataFlow::Node node) { + exists(Method m, DataFlow::CallNode call | + m.hasQualifiedName("github.com/cli/cli/v2/api", "Client", ["REST", "RESTWithNext"]) and + call = m.getACall() and + node = call.getArgument(2) + ) + or + exists(Function f, DataFlow::CallNode call | + f.hasQualifiedName("net/http", "NewRequest") and + call = f.getACall() and + node = call.getArgument(1) + ) + or + exists(Function f, DataFlow::CallNode call | + f.hasQualifiedName("net/http", "NewRequestWithContext") and + call = f.getACall() and + node = call.getArgument(2) + ) + or + exists(Method m, DataFlow::CallNode call | + m.hasQualifiedName("net/http", "Client", ["Get", "Head", "Post", "PostForm"]) and + call = m.getACall() and + node = call.getArgument(0) + ) +} + +/** + * Holds when `node` is a call to the String method of one of the safeurl URL types: + * the SafeURL interface or either of its implementations, MutableSafeURL and + * ImmutableSafeURL. Matching all three keeps call sites free of explicit conversions: + * a value of the concrete type can be passed to the sink directly without first being + * assigned to a SafeURL typed variable. + */ +predicate isSafeurlStringCall(DataFlow::Node node) { + exists(Method m | + m.hasQualifiedName("github.com/cli/cli/v2/internal/safeurl", + ["SafeURL", "MutableSafeURL", "ImmutableSafeURL"], "String") and + node = m.getACall() + ) +} + +from DataFlow::Node sink +where + isHttpUrlArgument(sink) and + not isSafeurlStringCall(sink) +select sink, "This HTTP request URL is not passed directly as the result of safeurl.SafeURL.String." diff --git a/.github/codeql/queries/examples/UnsanitizedResponseToTerminalBad.go b/.github/codeql/queries/examples/UnsanitizedResponseToTerminalBad.go new file mode 100644 index 00000000000..eeb4698ba66 --- /dev/null +++ b/.github/codeql/queries/examples/UnsanitizedResponseToTerminalBad.go @@ -0,0 +1,34 @@ +package example + +import ( + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +type Options struct { + IO *iostreams.IOStreams + HTTPClient *http.Client + URL string +} + +func run(opts *Options) error { + resp, err := opts.HTTPClient.Get(opts.URL) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + // BAD: server-controlled bytes are written to IO.Out, which does not + // sanitize. Any ANSI escape sequences in the response will be rendered + // by the user's terminal. + fmt.Fprint(opts.IO.Out, string(body)) + return nil +} diff --git a/.github/codeql/queries/examples/UnsanitizedResponseToTerminalGood.go b/.github/codeql/queries/examples/UnsanitizedResponseToTerminalGood.go new file mode 100644 index 00000000000..0f907196f29 --- /dev/null +++ b/.github/codeql/queries/examples/UnsanitizedResponseToTerminalGood.go @@ -0,0 +1,52 @@ +package example + +import ( + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type Options struct { + IO *iostreams.IOStreams + HTTPClient *http.Client + URL string + + AllowEscapeSequences bool +} + +func newCmd() *cobra.Command { + opts := &Options{} + cmd := &cobra.Command{ + Use: "fetch", + RunE: func(*cobra.Command, []string) error { return run(opts) }, + } + cmd.Flags().BoolVar(&opts.AllowEscapeSequences, "allow-escape-sequences", false, + "Allow printing terminal escape sequences") + return cmd +} + +func run(opts *Options) error { + if opts.AllowEscapeSequences { + opts.IO.SetContentSanitization(false) + } + + resp, err := opts.HTTPClient.Get(opts.URL) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + // GOOD: external bytes flow through ContentOut, which sanitizes ANSI + // escape sequences by default. The --allow-escape-sequences flag is the + // documented opt-out for trusted content. + fmt.Fprint(opts.IO.ContentOut, string(body)) + return nil +} diff --git a/.github/codeql/queries/unsanitized-response-to-terminal.md b/.github/codeql/queries/unsanitized-response-to-terminal.md new file mode 100644 index 00000000000..90b5b8d8ae6 --- /dev/null +++ b/.github/codeql/queries/unsanitized-response-to-terminal.md @@ -0,0 +1,118 @@ + +# HTTP response content reaches a terminal without ContentOut or sanitization +Bytes consumed from an HTTP response body are server-controlled and may contain ANSI escape sequences. When those bytes reach a terminal writer without sanitization, a remote attacker can move the cursor, repaint the screen, fake a shell prompt, write to the clipboard via OSC sequences, or otherwise manipulate the user's terminal session. + +This query flags HTTP response content, including bytes reintroduced by base64 decoding, that reaches a terminal writer (`IOStreams.Out`, `IOStreams.ErrOut`, `os.Stdout`, or `os.Stderr`) without first being written to `IOStreams.ContentOut`, sanitized, or decoded as a structured format (e.g. `encoding/json`). + + +## Recommendation +Choose the writer based on the kind of content you are printing: + +* `IOStreams.Out` is for application output the developer authored: tables, prompts, formatted messages, color-coded status. It does not sanitize and never should, because the developer controls every byte that reaches it. +* `IOStreams.ContentOut` is for external content the developer did not author: HTTP response bodies, file contents fetched from a remote, anything where a third party chose the bytes. It sanitizes ANSI escape sequences by default. +These patterns satisfy the query: + +1. Label the content at its source as `iostreams.Untrusted` and print it with `String()` (or any `fmt` verb, which calls `String()`); the value sanitizes itself. Its `Raw()` method is the explicit opt-out and is still flagged if it reaches a terminal. +1. Write external bytes to `IOStreams.ContentOut`. +1. Decode the bytes into a structured value first (`json.Unmarshal`, `(*json.Decoder).Decode`); the fields you print afterwards are no longer raw external content. +1. For commands where the user has opted into raw output, add a per-command `--allow-escape-sequences` flag and call `opts.IO.SetContentSanitization(false)` before writing. The bytes still go through `ContentOut`, but ContentOut becomes a passthrough for that invocation. + +## Example +In the following BAD example, the response body is written directly to `IOStreams.Out`. A server can embed ANSI escape sequences in the response and they will be rendered by the user's terminal: + + +```go +package example + +import ( + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +type Options struct { + IO *iostreams.IOStreams + HTTPClient *http.Client + URL string +} + +func run(opts *Options) error { + resp, err := opts.HTTPClient.Get(opts.URL) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + // BAD: server-controlled bytes are written to IO.Out, which does not + // sanitize. Any ANSI escape sequences in the response will be rendered + // by the user's terminal. + fmt.Fprint(opts.IO.Out, string(body)) + return nil +} + +``` +In the following GOOD example, the same body is written to `IOStreams.ContentOut`, which sanitizes ANSI escape sequences. An `--allow-escape-sequences` flag is provided for users who explicitly want raw output for trusted content: + + +```go +package example + +import ( + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type Options struct { + IO *iostreams.IOStreams + HTTPClient *http.Client + URL string + + AllowEscapeSequences bool +} + +func newCmd() *cobra.Command { + opts := &Options{} + cmd := &cobra.Command{ + Use: "fetch", + RunE: func(*cobra.Command, []string) error { return run(opts) }, + } + cmd.Flags().BoolVar(&opts.AllowEscapeSequences, "allow-escape-sequences", false, + "Allow printing terminal escape sequences") + return cmd +} + +func run(opts *Options) error { + if opts.AllowEscapeSequences { + opts.IO.SetContentSanitization(false) + } + + resp, err := opts.HTTPClient.Get(opts.URL) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + // GOOD: external bytes flow through ContentOut, which sanitizes ANSI + // escape sequences by default. The --allow-escape-sequences flag is the + // documented opt-out for trusted content. + fmt.Fprint(opts.IO.ContentOut, string(body)) + return nil +} + +``` diff --git a/.github/codeql/queries/unsanitized-response-to-terminal.qhelp b/.github/codeql/queries/unsanitized-response-to-terminal.qhelp new file mode 100644 index 00000000000..af7bed1bf19 --- /dev/null +++ b/.github/codeql/queries/unsanitized-response-to-terminal.qhelp @@ -0,0 +1,84 @@ + + + +

+ Bytes consumed from an HTTP response body are server-controlled and may + contain ANSI escape sequences. When those bytes reach a terminal writer + without sanitization, a remote attacker can move the cursor, repaint the + screen, fake a shell prompt, write to the clipboard via OSC sequences, + or otherwise manipulate the user's terminal session. +

+

+ This query flags HTTP response content, including bytes reintroduced by + base64 decoding, that reaches a terminal writer + (IOStreams.Out, IOStreams.ErrOut, + os.Stdout, or os.Stderr) without first being + written to IOStreams.ContentOut, sanitized, or decoded as a + structured format (e.g. encoding/json). +

+
+ + +

+ Choose the writer based on the kind of content you are printing: +

+ +

+ These patterns satisfy the query: +

+
    +
  1. + Label the content at its source as iostreams.Untrusted and + print it with String() (or any fmt verb, which + calls String()); the value sanitizes itself. Its + Raw() method is the explicit opt-out and is still flagged if + it reaches a terminal. +
  2. +
  3. + Write external bytes to IOStreams.ContentOut. +
  4. +
  5. + Decode the bytes into a structured value first + (json.Unmarshal, (*json.Decoder).Decode); + the fields you print afterwards are no longer raw external content. +
  6. +
  7. + For commands where the user has opted into raw output, add a + per-command --allow-escape-sequences flag and call + opts.IO.SetContentSanitization(false) before writing. + The bytes still go through ContentOut, but ContentOut + becomes a passthrough for that invocation. +
  8. +
+
+ + +

+ In the following BAD example, the response body is written directly to + IOStreams.Out. A server can embed ANSI escape sequences in + the response and they will be rendered by the user's terminal: +

+ + +

+ In the following GOOD example, the same body is written to + IOStreams.ContentOut, which sanitizes ANSI escape + sequences. An --allow-escape-sequences flag is provided for users who + explicitly want raw output for trusted content: +

+ +
+
diff --git a/.github/codeql/queries/unsanitized-response-to-terminal.ql b/.github/codeql/queries/unsanitized-response-to-terminal.ql new file mode 100644 index 00000000000..6a1cf6c392f --- /dev/null +++ b/.github/codeql/queries/unsanitized-response-to-terminal.ql @@ -0,0 +1,239 @@ +/** + * @name HTTP response content reaches a terminal without ContentOut or sanitization + * @description Raw bytes consumed from an HTTP response body, or reintroduced by + * decoding base64, must either be written to `IOStreams.ContentOut` + * or wrapped with the asciisanitizer before reaching a terminal + * writer. The body is tracked across function boundaries, so a body + * returned from a fetch helper and consumed by its caller is still + * covered. Values produced by structured decoding (encoding/json) + * are trusted, since cli/cli's REST clients sanitize JSON bodies at + * the transport layer before decoding. + * @kind path-problem + * @problem.severity error + * @precision medium + * @id cli-cli/unsanitized-response-to-terminal + * @tags security + */ + +import go +import semmle.go.dataflow.TaintTracking + +// ContentOut is the blessed sanitizing writer. Writing raw content there is the +// safe choice, so it is excluded from the terminal sink set below. +predicate isContentOutRead(DataFlow::Node n) { + exists(Field f | + f.hasQualifiedName("github.com/cli/cli/v2/pkg/iostreams", "IOStreams", "ContentOut") and + n = f.getARead() + ) +} + +// Value flow from a ContentOut read so a ContentOut writer stored in a local +// variable is still recognised as the blessed sink. Plain DataFlow (not taint) +// is used so it does not leak across sibling fields of a shared IOStreams. +module ContentOutWriterConfig implements DataFlow::ConfigSig { + predicate isSource(DataFlow::Node n) { isContentOutRead(n) } + + predicate isSink(DataFlow::Node n) { exists(n) } +} + +module ContentOutWriterFlow = DataFlow::Global; + +predicate isContentOutWriter(DataFlow::Node n) { + isContentOutRead(n) or + exists(DataFlow::Node src | isContentOutRead(src) and ContentOutWriterFlow::flow(src, n)) +} + +// ANSI injection requires bytes to reach a terminal. The terminal-bound writers +// are os.Stdout / os.Stderr and the IOStreams.Out / IOStreams.ErrOut fields. +// File, socket, and buffer writers are not terminals and are intentionally out +// of scope. +predicate isTerminalWriterRead(DataFlow::Node n) { + ( + exists(Variable v | + v.hasQualifiedName("os", "Stdout") or v.hasQualifiedName("os", "Stderr") + | + n = v.getARead() + ) + or + exists(Field f | + f.hasQualifiedName("github.com/cli/cli/v2/pkg/iostreams", "IOStreams", "Out") or + f.hasQualifiedName("github.com/cli/cli/v2/pkg/iostreams", "IOStreams", "ErrOut") + | + n = f.getARead() + ) + ) and + not isContentOutRead(n) +} + +// Value flow from a terminal-writer read so aliased writers +// (`w := opts.IO.Out; fmt.Fprint(w, ...)`) still count as terminal sinks. Plain +// DataFlow (not taint) is used so it does not leak across sibling fields of a +// shared IOStreams (which would otherwise mark ContentOut as terminal-bound). +module TerminalWriterConfig implements DataFlow::ConfigSig { + predicate isSource(DataFlow::Node n) { isTerminalWriterRead(n) } + + predicate isSink(DataFlow::Node n) { exists(n) } +} + +module TerminalWriterFlow = DataFlow::Global; + +predicate isTerminalBoundWriter(DataFlow::Node n) { + isTerminalWriterRead(n) or + exists(DataFlow::Node src | isTerminalWriterRead(src) and TerminalWriterFlow::flow(src, n)) +} + +// Raw HTTP body reader. Sourcing at the field read (rather than a local +// consumption call) lets global taint carry the reader across returns and +// parameters before anything reads it. +predicate isResponseBodyReader(DataFlow::Node n) { + exists(Field bodyField | + bodyField.hasQualifiedName("net/http", "Response", "Body") and + n = bodyField.getARead() + ) +} + +// Base64 decoding reintroduces raw bytes that any text sanitization applied to +// the encoded form never saw, so the decoded stream is its own source. +predicate isBase64DecodeSource(DataFlow::Node n) { + exists(DataFlow::CallNode c | + c.getTarget().hasQualifiedName("encoding/base64", "NewDecoder") and n = c + ) + or + exists(DataFlow::MethodCallNode c | + c.getTarget().hasQualifiedName("encoding/base64", "Encoding", "DecodeString") and n = c + ) +} + +// Carry taint from a reader value into the bytes a read produces, so the flow +// continues from the reader to wherever those bytes are written. +predicate isReaderConsumptionStep(DataFlow::Node pred, DataFlow::Node succ) { + exists(DataFlow::CallNode call | + ( + call.getTarget().hasQualifiedName("io", "ReadAll") or + call.getTarget().hasQualifiedName("io/ioutil", "ReadAll") + ) and + pred = call.getArgument(0) and + // ReadAll returns (bytes, error). Track taint into result 0, the bytes, only. + // The error result is an I/O or decode failure string, never response data, + // so tracking it would flag code that merely prints that error. + succ = call.getResult(0) + ) + or + exists(DataFlow::CallNode call | + call.getTarget().hasQualifiedName("bufio", "NewScanner") and + pred = call.getArgument(0) and + succ = call + ) + or + exists(DataFlow::MethodCallNode read | + ( + read.getTarget().hasQualifiedName("bufio", "Scanner", "Text") or + read.getTarget().hasQualifiedName("bufio", "Scanner", "Bytes") + ) and + pred = read.getReceiver() and + succ = read + ) +} + +// The asciisanitizer wrap is the blessed barrier. Both Sanitizer{} and +// &Sanitizer{} forms are accepted, regardless of which fields are set. +predicate isSanitizerBarrier(DataFlow::Node n) { + exists(DataFlow::CallNode c, Type argTy | + c.getTarget().hasQualifiedName("golang.org/x/text/transform", "NewReader") and + argTy = c.getArgument(1).getType() and + ( + argTy.hasQualifiedName("github.com/cli/go-gh/v2/pkg/asciisanitizer", "Sanitizer") or + argTy + .(PointerType) + .getBaseType() + .hasQualifiedName("github.com/cli/go-gh/v2/pkg/asciisanitizer", "Sanitizer") + ) and + n = c + ) +} + +// Structured decoding is trusted. Both `json.Unmarshal` and +// `(*json.Decoder).Decode` go through cli/cli's REST clients, which are built on +// go-gh's sanitizing transport: for JSON content types the body is sanitized +// before any decode. A decoded value is therefore not raw external content. +predicate isStructuredDecodeBarrier(DataFlow::Node n) { + exists(DataFlow::CallNode c | + c.getTarget().hasQualifiedName("encoding/json", "Unmarshal") and + n = c.getArgument(0) + ) + or + exists(DataFlow::MethodCallNode c | + c.getTarget().hasQualifiedName("encoding/json", "Decoder", "Decode") and + n = c.getReceiver() + ) +} + +// iostreams.Untrusted.String() returns content with ANSI escapes neutralized, so +// its result is sanitized. Raw and RawBytes are the deliberate opt-out and are +// intentionally NOT barriers, so a value taken out through them stays tracked to +// the terminal (unless the destination is ContentOut, which sanitizes itself). +predicate isUntrustedStringBarrier(DataFlow::Node n) { + exists(DataFlow::MethodCallNode c | + c.getTarget().hasQualifiedName("github.com/cli/cli/v2/pkg/iostreams", "Untrusted", "String") and + n = c + ) +} + +module UnsanitizedResponseConfig implements DataFlow::ConfigSig { + predicate isSource(DataFlow::Node n) { + isResponseBodyReader(n) or isBase64DecodeSource(n) + } + + predicate isSink(DataFlow::Node n) { + exists(DataFlow::CallNode call, int i | + ( + call.getTarget().hasQualifiedName("fmt", "Fprint") or + call.getTarget().hasQualifiedName("fmt", "Fprintln") or + call.getTarget().hasQualifiedName("fmt", "Fprintf") + ) and + isTerminalBoundWriter(call.getArgument(0)) and + not isContentOutWriter(call.getArgument(0)) and + i >= 1 and + n = call.getArgument(i) + ) + or + exists(DataFlow::CallNode call | + ( + call.getTarget().hasQualifiedName("io", "Copy") or + call.getTarget().hasQualifiedName("io", "CopyBuffer") + ) and + isTerminalBoundWriter(call.getArgument(0)) and + not isContentOutWriter(call.getArgument(0)) and + n = call.getArgument(1) + ) + or + exists(DataFlow::MethodCallNode call | + call.getTarget().getName() = "Write" and + isTerminalBoundWriter(call.getReceiver()) and + not isContentOutWriter(call.getReceiver()) and + n = call.getArgument(0) + ) + } + + predicate isBarrier(DataFlow::Node n) { + isSanitizerBarrier(n) or isStructuredDecodeBarrier(n) or isUntrustedStringBarrier(n) + } + + predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) { + isReaderConsumptionStep(pred, succ) + } +} + +module UnsanitizedResponseFlow = TaintTracking::Global; + +import UnsanitizedResponseFlow::PathGraph + +from UnsanitizedResponseFlow::PathNode source, UnsanitizedResponseFlow::PathNode sink +where + UnsanitizedResponseFlow::flowPath(source, sink) and + not sink.getNode().getFile().getRelativePath().regexpMatch(".*_test\\.go") and + not sink.getNode().getFile().getRelativePath().regexpMatch("internal/fake_vuln/.*") and + not sink.getNode().getFile().getRelativePath().regexpMatch("\\.github/codeql/tests/.*") +select sink.getNode(), source, sink, + "HTTP response content reaches a terminal writer that is not IOStreams.ContentOut. " + + "Write external content to opts.IO.ContentOut, or wrap it with the asciisanitizer." diff --git a/.github/codeql/tests/.gitignore b/.github/codeql/tests/.gitignore new file mode 100644 index 00000000000..e24a007a083 --- /dev/null +++ b/.github/codeql/tests/.gitignore @@ -0,0 +1,3 @@ +# CodeQL test runner outputs (regenerated each run) +*.testproj/ +*.actual diff --git a/.github/codeql/tests/codeql-pack.lock.yml b/.github/codeql/tests/codeql-pack.lock.yml new file mode 100644 index 00000000000..357ee5dab5c --- /dev/null +++ b/.github/codeql/tests/codeql-pack.lock.yml @@ -0,0 +1,24 @@ +--- +lockVersion: 1.0.0 +dependencies: + codeql/concepts: + version: 0.0.24 + codeql/controlflow: + version: 2.0.34 + codeql/dataflow: + version: 2.1.6 + codeql/go-all: + version: 7.1.1 + codeql/mad: + version: 1.0.50 + codeql/ssa: + version: 2.0.26 + codeql/threat-models: + version: 1.0.50 + codeql/tutorial: + version: 1.0.50 + codeql/typetracking: + version: 2.0.34 + codeql/util: + version: 2.0.37 +compiled: false diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/go.mod b/.github/codeql/tests/unsanitized-response-to-terminal/go.mod new file mode 100644 index 00000000000..6ed3c2fec7c --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/go.mod @@ -0,0 +1,8 @@ +module github.com/cli/cli/v2 + +go 1.25.0 + +require ( + github.com/cli/go-gh/v2 v2.13.0 + golang.org/x/text v0.37.0 +) diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/go.sum b/.github/codeql/tests/unsanitized-response-to-terminal/go.sum new file mode 100644 index 00000000000..249ff20e6a1 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/go.sum @@ -0,0 +1,4 @@ +github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys= +github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/hits_base64_after_json.go b/.github/codeql/tests/unsanitized-response-to-terminal/hits_base64_after_json.go new file mode 100644 index 00000000000..112683a9cef --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/hits_base64_after_json.go @@ -0,0 +1,34 @@ +package fixtures + +import ( + "encoding/base64" + "fmt" + "io" + "strings" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// A base64 field decoded back into raw bytes, then printed to Out. The decode +// reintroduces content that escaped any sanitization of the encoded text. Must +// be flagged. +type blobResponse struct { + Content string +} + +func fetchBlob(resp blobResponse) (string, error) { + decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(resp.Content))) + if err != nil { + return "", err + } + return string(decoded), nil +} + +func PreviewBlob(resp blobResponse, ios *iostreams.IOStreams) error { + content, err := fetchBlob(resp) + if err != nil { + return err + } + fmt.Fprint(ios.Out, content) + return nil +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/hits_iocopy_crossfunc.go b/.github/codeql/tests/unsanitized-response-to-terminal/hits_iocopy_crossfunc.go new file mode 100644 index 00000000000..1b652eece62 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/hits_iocopy_crossfunc.go @@ -0,0 +1,28 @@ +package fixtures + +import ( + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// A helper returns the raw body; the caller streams it to Out from a different +// function. Must be flagged. +func fetchBodyForCopy(url string) (io.ReadCloser, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + return resp.Body, nil +} + +func CopyBodyToOut(url string, ios *iostreams.IOStreams) error { + r, err := fetchBodyForCopy(url) + if err != nil { + return err + } + defer r.Close() + _, err = io.Copy(ios.Out, r) + return err +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/hits_readall_intraproc.go b/.github/codeql/tests/unsanitized-response-to-terminal/hits_readall_intraproc.go new file mode 100644 index 00000000000..5dd63daff39 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/hits_readall_intraproc.go @@ -0,0 +1,19 @@ +package fixtures + +import ( + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// io.ReadAll of the body, printed to Out in the same function. Must be flagged. +func ReadAllToOut(resp *http.Response, ios *iostreams.IOStreams) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + fmt.Fprintln(ios.Out, string(body)) + return nil +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/hits_scanner_crossfunc.go b/.github/codeql/tests/unsanitized-response-to-terminal/hits_scanner_crossfunc.go new file mode 100644 index 00000000000..e7867873467 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/hits_scanner_crossfunc.go @@ -0,0 +1,33 @@ +package fixtures + +import ( + "bufio" + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// A helper returns the raw body; the caller scans it line by line and prints to +// Out. Must be flagged. +func fetchLog(url string) (io.ReadCloser, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + return resp.Body, nil +} + +func ScanLogToOut(url string, ios *iostreams.IOStreams) error { + rc, err := fetchLog(url) + if err != nil { + return err + } + defer rc.Close() + scanner := bufio.NewScanner(rc) + for scanner.Scan() { + fmt.Fprintf(ios.Out, "%s\n", scanner.Text()) + } + return nil +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/hits_untrusted_raw.go b/.github/codeql/tests/unsanitized-response-to-terminal/hits_untrusted_raw.go new file mode 100644 index 00000000000..e51ff8193fc --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/hits_untrusted_raw.go @@ -0,0 +1,22 @@ +package fixtures + +import ( + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// A body minted as Untrusted but taken out through Raw and printed. Raw is the +// deliberate opt-out and is not a barrier, so the content reaches the terminal +// raw and must be flagged. +func RawEscapeHatchToOut(resp *http.Response, ios *iostreams.IOStreams) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + u := iostreams.NewUntrustedBytes(body) + fmt.Fprintln(ios.Out, u.Raw()) + return nil +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/misses_contentout.go b/.github/codeql/tests/unsanitized-response-to-terminal/misses_contentout.go new file mode 100644 index 00000000000..72edc981ca4 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/misses_contentout.go @@ -0,0 +1,34 @@ +package fixtures + +import ( + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// Raw body written to the blessed ContentOut writer. Must NOT be flagged. +func ReadAllToContentOut(url string, ios *iostreams.IOStreams) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + fmt.Fprintln(ios.ContentOut, string(body)) + return nil +} + +func CopyToContentOut(url string, ios *iostreams.IOStreams) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + _, err = io.Copy(ios.ContentOut, resp.Body) + return err +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/misses_decoder_decode.go b/.github/codeql/tests/unsanitized-response-to-terminal/misses_decoder_decode.go new file mode 100644 index 00000000000..ae5fb2656ae --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/misses_decoder_decode.go @@ -0,0 +1,29 @@ +package fixtures + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// json.NewDecoder on the body. The transport JSON sanitizer feeds this path, so +// the Decode barrier keeps the query silent. Must NOT be flagged. +type issueView struct { + Title string +} + +func ViewIssueTitle(url string, ios *iostreams.IOStreams) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + var iss issueView + if err := json.NewDecoder(resp.Body).Decode(&iss); err != nil { + return err + } + fmt.Fprintln(ios.Out, iss.Title) + return nil +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/misses_disk_roundtrip.go b/.github/codeql/tests/unsanitized-response-to-terminal/misses_disk_roundtrip.go new file mode 100644 index 00000000000..af476bd8c51 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/misses_disk_roundtrip.go @@ -0,0 +1,43 @@ +package fixtures + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// The body is written to a disk cache, then reopened and printed. Static taint +// cannot bridge the filesystem, so the query is silent here by necessity. The +// runtime ContentOut writer is what covers this case. Documented as a known +// limitation; must NOT be flagged. +func cacheBody(url, path string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, resp.Body) + return err +} + +func PrintCachedFile(path string, ios *iostreams.IOStreams) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fmt.Fprintf(ios.Out, "%s\n", scanner.Text()) + } + return nil +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/misses_field_after_unmarshal.go b/.github/codeql/tests/unsanitized-response-to-terminal/misses_field_after_unmarshal.go new file mode 100644 index 00000000000..69207533d50 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/misses_field_after_unmarshal.go @@ -0,0 +1,41 @@ +package fixtures + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// A helper reads the raw body into bytes; the caller json.Unmarshals it and +// prints a decoded field. Statically this is identical to the safe case where an +// already-sanitized JSON response is decoded and a field printed, so the query +// stays silent on purpose. The runtime ContentOut writer is the mitigation. Must +// NOT be flagged. +type logEntry struct { + Content string +} + +func fetchLogBytes(url string) ([]byte, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +func RenderLogField(url string, ios *iostreams.IOStreams) error { + raw, err := fetchLogBytes(url) + if err != nil { + return err + } + var entry logEntry + if err := json.Unmarshal(raw, &entry); err != nil { + return err + } + fmt.Fprintln(ios.Out, entry.Content) + return nil +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/misses_file_sink.go b/.github/codeql/tests/unsanitized-response-to-terminal/misses_file_sink.go new file mode 100644 index 00000000000..e9c9b8eee37 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/misses_file_sink.go @@ -0,0 +1,23 @@ +package fixtures + +import ( + "io" + "net/http" + "os" +) + +// The body is copied to a file on disk, not a terminal. Must NOT be flagged. +func DownloadToFile(url, path string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, resp.Body) + return err +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/misses_sanitized.go b/.github/codeql/tests/unsanitized-response-to-terminal/misses_sanitized.go new file mode 100644 index 00000000000..83f505cf1fb --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/misses_sanitized.go @@ -0,0 +1,40 @@ +package fixtures + +import ( + "bufio" + "fmt" + "io" + "net/http" + + "github.com/cli/go-gh/v2/pkg/asciisanitizer" + "golang.org/x/text/transform" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// The body is wrapped with the asciisanitizer transform before printing. Must +// NOT be flagged. +func SanitizedScanToOut(url string, ios *iostreams.IOStreams) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + sanitized := transform.NewReader(resp.Body, &asciisanitizer.Sanitizer{}) + scanner := bufio.NewScanner(sanitized) + for scanner.Scan() { + fmt.Fprintf(ios.Out, "%s\n", scanner.Text()) + } + return nil +} + +func SanitizedCopyToOut(url string, ios *iostreams.IOStreams) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + sanitized := transform.NewReader(resp.Body, &asciisanitizer.Sanitizer{}) + _, err = io.Copy(ios.Out, sanitized) + return err +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/misses_untrusted_string.go b/.github/codeql/tests/unsanitized-response-to-terminal/misses_untrusted_string.go new file mode 100644 index 00000000000..cb2b5b2a434 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/misses_untrusted_string.go @@ -0,0 +1,21 @@ +package fixtures + +import ( + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +// A body minted as Untrusted and printed through String(), which sanitizes. Must +// NOT be flagged. +func UntrustedStringToOut(resp *http.Response, ios *iostreams.IOStreams) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + u := iostreams.NewUntrustedBytes(body) + fmt.Fprintln(ios.Out, u.String()) + return nil +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/pkg/iostreams/iostreams.go b/.github/codeql/tests/unsanitized-response-to-terminal/pkg/iostreams/iostreams.go new file mode 100644 index 00000000000..7a46dc7fe8b --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/pkg/iostreams/iostreams.go @@ -0,0 +1,29 @@ +package iostreams + +import "io" + +// IOStreams is a minimal stub mirroring the real package's writer fields so the +// query can match Out / ErrOut / ContentOut by qualified name in tests. +type IOStreams struct { + Out io.Writer + ErrOut io.Writer + ContentOut io.Writer +} + +// Untrusted is a minimal stub of the real provenance type so fixtures can mint +// and unwrap external content and the query can match String / Raw by qualified +// name. +type Untrusted struct { + raw string +} + +func NewUntrusted(s string) Untrusted { return Untrusted{raw: s} } + +func NewUntrustedBytes(b []byte) Untrusted { return Untrusted{raw: string(b)} } + +func (u Untrusted) String() string { return sanitizeStub(u.raw) } + +func (u Untrusted) Raw() string { return u.raw } + +func sanitizeStub(s string) string { return s } + diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/test.expected b/.github/codeql/tests/unsanitized-response-to-terminal/test.expected new file mode 100644 index 00000000000..ad9a2c7bc32 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/test.expected @@ -0,0 +1,77 @@ +edges +| hits_base64_after_json.go:20:2:20:99 | ... := ...[0] | hits_base64_after_json.go:24:9:24:23 | type conversion | provenance | | +| hits_base64_after_json.go:20:29:20:98 | call to NewDecoder | hits_base64_after_json.go:20:2:20:99 | ... := ...[0] | provenance | Config | +| hits_base64_after_json.go:20:29:20:98 | call to NewDecoder | hits_base64_after_json.go:20:2:20:99 | ... := ...[0] | provenance | MaD:1773 | +| hits_base64_after_json.go:24:9:24:23 | type conversion | hits_base64_after_json.go:28:2:28:32 | ... := ...[0] | provenance | | +| hits_base64_after_json.go:28:2:28:32 | ... := ...[0] | hits_base64_after_json.go:32:22:32:28 | content | provenance | | +| hits_base64_after_json.go:32:22:32:28 | content | hits_base64_after_json.go:32:2:32:29 | []type{args} | provenance | | +| hits_iocopy_crossfunc.go:17:9:17:17 | selection of Body | hits_iocopy_crossfunc.go:21:2:21:32 | ... := ...[0] | provenance | | +| hits_iocopy_crossfunc.go:21:2:21:32 | ... := ...[0] | hits_iocopy_crossfunc.go:26:28:26:28 | r | provenance | | +| hits_readall_intraproc.go:13:2:13:35 | ... := ...[0] | hits_readall_intraproc.go:17:24:17:35 | type conversion | provenance | | +| hits_readall_intraproc.go:13:26:13:34 | selection of Body | hits_readall_intraproc.go:13:2:13:35 | ... := ...[0] | provenance | Config | +| hits_readall_intraproc.go:13:26:13:34 | selection of Body | hits_readall_intraproc.go:13:2:13:35 | ... := ...[0] | provenance | MaD:1773 | +| hits_readall_intraproc.go:17:24:17:35 | type conversion | hits_readall_intraproc.go:17:2:17:36 | []type{args} | provenance | | +| hits_scanner_crossfunc.go:19:9:19:17 | selection of Body | hits_scanner_crossfunc.go:23:2:23:25 | ... := ...[0] | provenance | | +| hits_scanner_crossfunc.go:23:2:23:25 | ... := ...[0] | hits_scanner_crossfunc.go:28:30:28:31 | rc | provenance | | +| hits_scanner_crossfunc.go:28:13:28:32 | call to NewScanner | hits_scanner_crossfunc.go:30:32:30:38 | scanner | provenance | | +| hits_scanner_crossfunc.go:28:30:28:31 | rc | hits_scanner_crossfunc.go:28:13:28:32 | call to NewScanner | provenance | Config | +| hits_scanner_crossfunc.go:28:30:28:31 | rc | hits_scanner_crossfunc.go:28:13:28:32 | call to NewScanner | provenance | MaD:14 | +| hits_scanner_crossfunc.go:30:32:30:38 | scanner | hits_scanner_crossfunc.go:30:32:30:45 | call to Text | provenance | Config | +| hits_scanner_crossfunc.go:30:32:30:38 | scanner | hits_scanner_crossfunc.go:30:32:30:45 | call to Text | provenance | MaD:26 | +| hits_scanner_crossfunc.go:30:32:30:45 | call to Text | hits_scanner_crossfunc.go:30:3:30:46 | []type{args} | provenance | | +| hits_untrusted_raw.go:15:2:15:35 | ... := ...[0] | hits_untrusted_raw.go:19:35:19:38 | body | provenance | | +| hits_untrusted_raw.go:15:26:15:34 | selection of Body | hits_untrusted_raw.go:15:2:15:35 | ... := ...[0] | provenance | Config | +| hits_untrusted_raw.go:15:26:15:34 | selection of Body | hits_untrusted_raw.go:15:2:15:35 | ... := ...[0] | provenance | MaD:1773 | +| hits_untrusted_raw.go:19:7:19:39 | call to NewUntrustedBytes [raw] | hits_untrusted_raw.go:20:24:20:24 | u [raw] | provenance | | +| hits_untrusted_raw.go:19:35:19:38 | body | hits_untrusted_raw.go:19:7:19:39 | call to NewUntrustedBytes [raw] | provenance | | +| hits_untrusted_raw.go:19:35:19:38 | body | pkg/iostreams/iostreams.go:22:24:22:24 | definition of b | provenance | | +| hits_untrusted_raw.go:20:24:20:24 | u [raw] | hits_untrusted_raw.go:20:24:20:30 | call to Raw | provenance | | +| hits_untrusted_raw.go:20:24:20:24 | u [raw] | pkg/iostreams/iostreams.go:26:7:26:7 | definition of u [raw] | provenance | | +| hits_untrusted_raw.go:20:24:20:30 | call to Raw | hits_untrusted_raw.go:20:2:20:31 | []type{args} | provenance | | +| pkg/iostreams/iostreams.go:22:24:22:24 | definition of b | pkg/iostreams/iostreams.go:22:68:22:76 | type conversion | provenance | | +| pkg/iostreams/iostreams.go:22:68:22:76 | type conversion | pkg/iostreams/iostreams.go:22:53:22:77 | struct literal [raw] | provenance | | +| pkg/iostreams/iostreams.go:26:7:26:7 | definition of u [raw] | pkg/iostreams/iostreams.go:26:42:26:42 | u [raw] | provenance | | +| pkg/iostreams/iostreams.go:26:42:26:42 | u [raw] | pkg/iostreams/iostreams.go:26:42:26:46 | selection of raw | provenance | | +nodes +| hits_base64_after_json.go:20:2:20:99 | ... := ...[0] | semmle.label | ... := ...[0] | +| hits_base64_after_json.go:20:29:20:98 | call to NewDecoder | semmle.label | call to NewDecoder | +| hits_base64_after_json.go:24:9:24:23 | type conversion | semmle.label | type conversion | +| hits_base64_after_json.go:28:2:28:32 | ... := ...[0] | semmle.label | ... := ...[0] | +| hits_base64_after_json.go:32:2:32:29 | []type{args} | semmle.label | []type{args} | +| hits_base64_after_json.go:32:22:32:28 | content | semmle.label | content | +| hits_iocopy_crossfunc.go:17:9:17:17 | selection of Body | semmle.label | selection of Body | +| hits_iocopy_crossfunc.go:21:2:21:32 | ... := ...[0] | semmle.label | ... := ...[0] | +| hits_iocopy_crossfunc.go:26:28:26:28 | r | semmle.label | r | +| hits_readall_intraproc.go:13:2:13:35 | ... := ...[0] | semmle.label | ... := ...[0] | +| hits_readall_intraproc.go:13:26:13:34 | selection of Body | semmle.label | selection of Body | +| hits_readall_intraproc.go:17:2:17:36 | []type{args} | semmle.label | []type{args} | +| hits_readall_intraproc.go:17:24:17:35 | type conversion | semmle.label | type conversion | +| hits_scanner_crossfunc.go:19:9:19:17 | selection of Body | semmle.label | selection of Body | +| hits_scanner_crossfunc.go:23:2:23:25 | ... := ...[0] | semmle.label | ... := ...[0] | +| hits_scanner_crossfunc.go:28:13:28:32 | call to NewScanner | semmle.label | call to NewScanner | +| hits_scanner_crossfunc.go:28:30:28:31 | rc | semmle.label | rc | +| hits_scanner_crossfunc.go:30:3:30:46 | []type{args} | semmle.label | []type{args} | +| hits_scanner_crossfunc.go:30:32:30:38 | scanner | semmle.label | scanner | +| hits_scanner_crossfunc.go:30:32:30:45 | call to Text | semmle.label | call to Text | +| hits_untrusted_raw.go:15:2:15:35 | ... := ...[0] | semmle.label | ... := ...[0] | +| hits_untrusted_raw.go:15:26:15:34 | selection of Body | semmle.label | selection of Body | +| hits_untrusted_raw.go:19:7:19:39 | call to NewUntrustedBytes [raw] | semmle.label | call to NewUntrustedBytes [raw] | +| hits_untrusted_raw.go:19:35:19:38 | body | semmle.label | body | +| hits_untrusted_raw.go:20:2:20:31 | []type{args} | semmle.label | []type{args} | +| hits_untrusted_raw.go:20:24:20:24 | u [raw] | semmle.label | u [raw] | +| hits_untrusted_raw.go:20:24:20:30 | call to Raw | semmle.label | call to Raw | +| pkg/iostreams/iostreams.go:22:24:22:24 | definition of b | semmle.label | definition of b | +| pkg/iostreams/iostreams.go:22:53:22:77 | struct literal [raw] | semmle.label | struct literal [raw] | +| pkg/iostreams/iostreams.go:22:68:22:76 | type conversion | semmle.label | type conversion | +| pkg/iostreams/iostreams.go:26:7:26:7 | definition of u [raw] | semmle.label | definition of u [raw] | +| pkg/iostreams/iostreams.go:26:42:26:42 | u [raw] | semmle.label | u [raw] | +| pkg/iostreams/iostreams.go:26:42:26:46 | selection of raw | semmle.label | selection of raw | +subpaths +| hits_untrusted_raw.go:19:35:19:38 | body | pkg/iostreams/iostreams.go:22:24:22:24 | definition of b | pkg/iostreams/iostreams.go:22:53:22:77 | struct literal [raw] | hits_untrusted_raw.go:19:7:19:39 | call to NewUntrustedBytes [raw] | +| hits_untrusted_raw.go:20:24:20:24 | u [raw] | pkg/iostreams/iostreams.go:26:7:26:7 | definition of u [raw] | pkg/iostreams/iostreams.go:26:42:26:46 | selection of raw | hits_untrusted_raw.go:20:24:20:30 | call to Raw | +#select +| hits_base64_after_json.go:32:2:32:29 | []type{args} | hits_base64_after_json.go:20:29:20:98 | call to NewDecoder | hits_base64_after_json.go:32:2:32:29 | []type{args} | HTTP response content reaches a terminal writer that is not IOStreams.ContentOut. Write external content to opts.IO.ContentOut, or wrap it with the asciisanitizer. | +| hits_iocopy_crossfunc.go:26:28:26:28 | r | hits_iocopy_crossfunc.go:17:9:17:17 | selection of Body | hits_iocopy_crossfunc.go:26:28:26:28 | r | HTTP response content reaches a terminal writer that is not IOStreams.ContentOut. Write external content to opts.IO.ContentOut, or wrap it with the asciisanitizer. | +| hits_readall_intraproc.go:17:2:17:36 | []type{args} | hits_readall_intraproc.go:13:26:13:34 | selection of Body | hits_readall_intraproc.go:17:2:17:36 | []type{args} | HTTP response content reaches a terminal writer that is not IOStreams.ContentOut. Write external content to opts.IO.ContentOut, or wrap it with the asciisanitizer. | +| hits_scanner_crossfunc.go:30:3:30:46 | []type{args} | hits_scanner_crossfunc.go:19:9:19:17 | selection of Body | hits_scanner_crossfunc.go:30:3:30:46 | []type{args} | HTTP response content reaches a terminal writer that is not IOStreams.ContentOut. Write external content to opts.IO.ContentOut, or wrap it with the asciisanitizer. | +| hits_untrusted_raw.go:20:2:20:31 | []type{args} | hits_untrusted_raw.go:15:26:15:34 | selection of Body | hits_untrusted_raw.go:20:2:20:31 | []type{args} | HTTP response content reaches a terminal writer that is not IOStreams.ContentOut. Write external content to opts.IO.ContentOut, or wrap it with the asciisanitizer. | diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/test.qlref b/.github/codeql/tests/unsanitized-response-to-terminal/test.qlref new file mode 100644 index 00000000000..843fbea4062 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/test.qlref @@ -0,0 +1 @@ +queries/unsanitized-response-to-terminal.ql diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/vendor/github.com/cli/go-gh/v2/pkg/asciisanitizer/sanitizer.go b/.github/codeql/tests/unsanitized-response-to-terminal/vendor/github.com/cli/go-gh/v2/pkg/asciisanitizer/sanitizer.go new file mode 100644 index 00000000000..9a02658b178 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/vendor/github.com/cli/go-gh/v2/pkg/asciisanitizer/sanitizer.go @@ -0,0 +1,12 @@ +// Minimal stub of github.com/cli/go-gh/v2/pkg/asciisanitizer for CodeQL test +// extraction. Only needs the Sanitizer type to exist under the expected +// qualified name so the barrier predicate's hasQualifiedName check matches. +package asciisanitizer + +type Sanitizer struct{} + +func (s *Sanitizer) Reset() {} + +func (s *Sanitizer) Transform(dst, src []byte, atEOF bool) (int, int, error) { + return 0, 0, nil +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/vendor/golang.org/x/text/transform/reader.go b/.github/codeql/tests/unsanitized-response-to-terminal/vendor/golang.org/x/text/transform/reader.go new file mode 100644 index 00000000000..7a54f60b4d1 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/vendor/golang.org/x/text/transform/reader.go @@ -0,0 +1,18 @@ +// Minimal stub of golang.org/x/text/transform for CodeQL test extraction. +// Only needs NewReader to exist under the expected qualified name. +package transform + +import "io" + +type Transformer interface { + Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) + Reset() +} + +type Reader struct{ r io.Reader } + +func (r *Reader) Read(p []byte) (int, error) { return r.r.Read(p) } + +func NewReader(r io.Reader, t Transformer) *Reader { + return &Reader{r: r} +} diff --git a/.github/codeql/tests/unsanitized-response-to-terminal/vendor/modules.txt b/.github/codeql/tests/unsanitized-response-to-terminal/vendor/modules.txt new file mode 100644 index 00000000000..44bdf242178 --- /dev/null +++ b/.github/codeql/tests/unsanitized-response-to-terminal/vendor/modules.txt @@ -0,0 +1,6 @@ +# github.com/cli/go-gh/v2 v2.13.0 +## explicit; go 1.21 +github.com/cli/go-gh/v2/pkg/asciisanitizer +# golang.org/x/text v0.37.0 +## explicit; go 1.21 +golang.org/x/text/transform diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ab456c1c577..dfee2a817ce 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,6 +9,7 @@ on: - '**/*.md' schedule: - cron: "0 0 * * 0" + workflow_dispatch: permissions: actions: read # for github/codeql-action/init to get workflow details @@ -21,7 +22,18 @@ jobs: strategy: fail-fast: false matrix: - language: ['go', 'actions'] + include: + # Go uses our custom config, which extends `security-and-quality` + # with the project-specific queries under `.github/codeql/queries/`. + # `build-mode: manual` runs our own build below so extraction is scoped + # to the main module and never co-extracts the nested query-test module. + - language: go + build-mode: manual + config-file: ./.github/codeql/codeql-config.yml + # Actions uses the stock `security-and-quality` suite. + - language: actions + build-mode: none + queries: security-and-quality steps: - name: Check out code @@ -37,7 +49,15 @@ jobs: uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: ${{ matrix.language }} - queries: security-and-quality + build-mode: ${{ matrix.build-mode }} + config-file: ${{ matrix.config-file }} + queries: ${{ matrix.queries }} + + # Mirror the shipped build (see go.yml integration-tests) so the analyzed + # code matches what we release. + - name: Build Go + if: matrix.language == 'go' + run: make - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 diff --git a/.gitignore b/.gitignore index 25549846a52..2d65ee64682 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,13 @@ *~ vendor/ +!.github/codeql/tests/**/vendor/ gh # Test coverage artifacts coverage.out lcov.info + +# CodeQL scratch database (regenerated locally) +codeql-db/ +*.sarif diff --git a/api/queries_pr.go b/api/queries_pr.go index 29342521f8c..10994958ace 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -3,10 +3,10 @@ package api import ( "fmt" "net/http" - "net/url" "time" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/shurcooL/githubv4" ) @@ -845,8 +845,11 @@ func ConvertPullRequestToDraft(client *Client, repo ghrepo.Interface, pr *PullRe } func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error { - path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), url.PathEscape(branch)) - return client.REST(repo.RepoHost(), "DELETE", path, nil, nil) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "git", "refs", fmt.Sprintf("heads/%s", branch)) + if err != nil { + return err + } + return client.REST(repo.RepoHost(), "DELETE", path.String(), nil, nil) } type RefComparison struct { diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index b0a602bf4c9..1526758cd9a 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -4,11 +4,12 @@ import ( "bytes" "encoding/json" "fmt" - "net/url" + "strconv" "strings" "time" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/shurcooL/githubv4" ) @@ -284,12 +285,17 @@ func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users = []string{} } - path := fmt.Sprintf( - "repos/%s/%s/pulls/%d/requested_reviewers", - url.PathEscape(repo.RepoOwner()), - url.PathEscape(repo.RepoName()), - prNumber, + path, err := safeurl.JoinPath( + "repos", + repo.RepoOwner(), + repo.RepoName(), + "pulls", + strconv.Itoa(prNumber), + "requested_reviewers", ) + if err != nil { + return err + } body := struct { Reviewers []string `json:"reviewers"` TeamReviewers []string `json:"team_reviewers"` @@ -302,7 +308,7 @@ func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, return err } // The endpoint responds with the updated pull request object; we don't need it here. - return client.REST(repo.RepoHost(), "POST", path, buf, nil) + return client.REST(repo.RepoHost(), "POST", path.String(), buf, nil) } // RemovePullRequestReviews removes requested reviewers from a pull request using the REST API. @@ -317,12 +323,17 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in users = []string{} } - path := fmt.Sprintf( - "repos/%s/%s/pulls/%d/requested_reviewers", - url.PathEscape(repo.RepoOwner()), - url.PathEscape(repo.RepoName()), - prNumber, + path, err := safeurl.JoinPath( + "repos", + repo.RepoOwner(), + repo.RepoName(), + "pulls", + strconv.Itoa(prNumber), + "requested_reviewers", ) + if err != nil { + return err + } body := struct { Reviewers []string `json:"reviewers"` TeamReviewers []string `json:"team_reviewers"` @@ -335,7 +346,7 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in return err } // The endpoint responds with the updated pull request object; we don't need it here. - return client.REST(repo.RepoHost(), "DELETE", path, buf, nil) + return client.REST(repo.RepoHost(), "DELETE", path.String(), buf, nil) } // RequestReviewsByLogin sets requested reviewers on a pull request using the GraphQL mutation. diff --git a/api/queries_pr_test.go b/api/queries_pr_test.go index 633b9a8c35f..cf7e7b04b81 100644 --- a/api/queries_pr_test.go +++ b/api/queries_pr_test.go @@ -23,7 +23,7 @@ func TestBranchDeleteRemote(t *testing.T) { branch: "owner/branch#123", httpStubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/owner%2Fbranch%23123"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fowner%2Fbranch%23123"), httpmock.StatusStringResponse(204, "")) }, expectError: false, @@ -33,7 +33,7 @@ func TestBranchDeleteRemote(t *testing.T) { branch: "my-branch", httpStubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/my-branch"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fmy-branch"), httpmock.StatusStringResponse(500, `{"message": "oh no"}`)) }, expectError: true, diff --git a/api/queries_repo.go b/api/queries_repo.go index d1bc2df1e29..3e0b75648cb 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -17,6 +17,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/shurcooL/githubv4" ) @@ -589,7 +590,10 @@ type repositoryV3 struct { // ForkRepo forks the repository on GitHub and returns the new repository func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string, defaultBranchOnly bool) (*Repository, error) { - path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo)) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "forks") + if err != nil { + return nil, err + } params := map[string]interface{}{} if org != "" { @@ -609,7 +613,7 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string, defaul } result := repositoryV3{} - err := client.REST(repo.RepoHost(), "POST", path, body, &result) + err = client.REST(repo.RepoHost(), "POST", path.String(), body, &result) if err != nil { return nil, err } @@ -643,12 +647,13 @@ func RenameRepo(client *Client, repo ghrepo.Interface, newRepoName string) (*Rep return nil, err } - path := fmt.Sprintf("%srepos/%s", - ghinstance.RESTPrefix(repo.RepoHost()), - ghrepo.FullName(repo)) + path, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName()) + if err != nil { + return nil, err + } result := repositoryV3{} - err := client.REST(repo.RepoHost(), "PATCH", path, body, &result) + err = client.REST(repo.RepoHost(), "PATCH", path.String(), body, &result) if err != nil { return nil, err } @@ -1608,9 +1613,9 @@ func v2Projects(client *Client, repo ghrepo.Interface) ([]ProjectV2, error) { return projectsV2, nil } -func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path string, body io.Reader) (*Repository, error) { +func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path safeurl.SafeURL, body io.Reader) (*Repository, error) { var responsev3 repositoryV3 - err := apiClient.REST(hostname, method, path, body, &responsev3) + err := apiClient.REST(hostname, method, path.String(), body, &responsev3) if err != nil { return nil, err @@ -1666,9 +1671,12 @@ func GetRepoIDs(client *Client, host string, repositories []ghrepo.Interface) ([ } func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) { - path := fmt.Sprintf("%srepos/%s/%s", ghinstance.RESTPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName()) + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName()) + if err != nil { + return false, err + } - resp, err := client.HTTP().Head(path) + resp, err := client.HTTP().Head(u.String()) if err != nil { return false, err } @@ -1690,7 +1698,11 @@ func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) { func RepoLicenses(httpClient *http.Client, hostname string) ([]License, error) { var licenses []License client := NewClientFromHTTP(httpClient) - err := client.REST(hostname, "GET", "licenses", nil, &licenses) + path, err := safeurl.JoinPath("licenses") + if err != nil { + return nil, err + } + err = client.REST(hostname, "GET", path.String(), nil, &licenses) if err != nil { return nil, err } @@ -1702,8 +1714,11 @@ func RepoLicenses(httpClient *http.Client, hostname string) ([]License, error) { func RepoLicense(httpClient *http.Client, hostname string, licenseName string) (*License, error) { var license License client := NewClientFromHTTP(httpClient) - path := fmt.Sprintf("licenses/%s", licenseName) - err := client.REST(hostname, "GET", path, nil, &license) + path, err := safeurl.JoinPath("licenses", licenseName) + if err != nil { + return nil, err + } + err = client.REST(hostname, "GET", path.String(), nil, &license) if err != nil { return nil, err } @@ -1715,7 +1730,11 @@ func RepoLicense(httpClient *http.Client, hostname string, licenseName string) ( func RepoGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, error) { var gitIgnoreTemplates []string client := NewClientFromHTTP(httpClient) - err := client.REST(hostname, "GET", "gitignore/templates", nil, &gitIgnoreTemplates) + path, err := safeurl.JoinPath("gitignore", "templates") + if err != nil { + return nil, err + } + err = client.REST(hostname, "GET", path.String(), nil, &gitIgnoreTemplates) if err != nil { return nil, err } @@ -1727,8 +1746,11 @@ func RepoGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, func RepoGitIgnoreTemplate(httpClient *http.Client, hostname string, gitIgnoreTemplateName string) (*GitIgnore, error) { var gitIgnoreTemplate GitIgnore client := NewClientFromHTTP(httpClient) - path := fmt.Sprintf("gitignore/templates/%s", gitIgnoreTemplateName) - err := client.REST(hostname, "GET", path, nil, &gitIgnoreTemplate) + path, err := safeurl.JoinPath("gitignore", "templates", gitIgnoreTemplateName) + if err != nil { + return nil, err + } + err = client.REST(hostname, "GET", path.String(), nil, &gitIgnoreTemplate) if err != nil { return nil, err } diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index df2e180d7aa..29a852cb68f 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -42,6 +42,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/opentracing/opentracing-go" ) @@ -115,7 +116,11 @@ func (a *API) ServerURL() string { // GetUser returns the user associated with the given token. func (a *API) GetUser(ctx context.Context) (*User, error) { - req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/user", nil) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "user") + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -160,7 +165,15 @@ type Repository struct { // GetRepository returns the repository associated with the given owner and name. func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error) { - req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+strings.ToLower(nwo), nil) + owner, name, err := safeurl.RepoPartsFromNWO(strings.ToLower(nwo)) + if err != nil { + return nil, err + } + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "repos", owner, name) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -364,31 +377,55 @@ func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (c } var ( - listURL string + listURL safeurl.SafeURL spanName string ) if opts.RepoName != "" { - listURL = fmt.Sprintf("%s/repos/%s/codespaces?per_page=%d", a.githubAPI, opts.RepoName, perPage) + owner, name, err := safeurl.RepoPartsFromNWO(opts.RepoName) + if err != nil { + return nil, err + } + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "repos", owner, name, "codespaces") + if err != nil { + return nil, err + } + u.SetQuery("per_page", strconv.Itoa(perPage)) + listURL = u spanName = "/repos/*/codespaces" } else if opts.OrgName != "" { // the endpoints below can only be called by the organization admins orgName := opts.OrgName if opts.UserName != "" { userName := opts.UserName - listURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces?per_page=%d", a.githubAPI, orgName, userName, perPage) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "orgs", orgName, "members", userName, "codespaces") + if err != nil { + return nil, err + } + u.SetQuery("per_page", strconv.Itoa(perPage)) + listURL = u spanName = "/orgs/*/members/*/codespaces" } else { - listURL = fmt.Sprintf("%s/orgs/%s/codespaces?per_page=%d", a.githubAPI, orgName, perPage) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "orgs", orgName, "codespaces") + if err != nil { + return nil, err + } + u.SetQuery("per_page", strconv.Itoa(perPage)) + listURL = u spanName = "/orgs/*/codespaces" } } else { - listURL = fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "user", "codespaces") + if err != nil { + return nil, err + } + u.SetQuery("per_page", strconv.Itoa(perPage)) + listURL = u spanName = "/user/codespaces" } for { - req, err := http.NewRequest(http.MethodGet, listURL, nil) + req, err := http.NewRequest(http.MethodGet, listURL.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -425,9 +462,9 @@ func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (c q := u.Query() q.Set("per_page", strconv.Itoa(newPerPage)) u.RawQuery = q.Encode() - listURL = u.String() + listURL = safeurl.NewImmutableSafeURL(u.String()) } else { - listURL = nextURL + listURL = safeurl.NewImmutableSafeURL(nextURL) } } @@ -447,10 +484,15 @@ func findNextPage(linkValue string) string { func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*Codespace, error) { perPage := 100 - listURL := fmt.Sprintf("%s/orgs/%s/members/%s/codespaces?per_page=%d", a.githubAPI, orgName, userName, perPage) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "orgs", orgName, "members", userName, "codespaces") + if err != nil { + return nil, err + } + u.SetQuery("per_page", strconv.Itoa(perPage)) + var listURL safeurl.SafeURL = u for { - req, err := http.NewRequest(http.MethodGet, listURL, nil) + req, err := http.NewRequest(http.MethodGet, listURL.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -485,7 +527,7 @@ func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userNam if nextURL == "" { break } - listURL = nextURL + listURL = safeurl.NewImmutableSafeURL(nextURL) } return nil, fmt.Errorf("codespace not found for user %s with name %s", userName, codespaceName) @@ -496,9 +538,13 @@ func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userNam // If includeConnection is true, it will return the connection information for the codespace. func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeConnection bool) (*Codespace, error) { resp, err := a.withRetry(func() (*http.Response, error) { + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "user", "codespaces", codespaceName) + if err != nil { + return nil, err + } req, err := http.NewRequest( http.MethodGet, - a.githubAPI+"/user/codespaces/"+codespaceName, + u.String(), nil, ) if err != nil { @@ -539,9 +585,13 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon // If the codespace is already running, the returned error from the API is ignored. func (a *API) StartCodespace(ctx context.Context, codespaceName string) error { resp, err := a.withRetry(func() (*http.Response, error) { + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "user", "codespaces", codespaceName, "start") + if err != nil { + return nil, err + } req, err := http.NewRequest( http.MethodPost, - a.githubAPI+"/user/codespaces/"+codespaceName+"/start", + u.String(), nil, ) if err != nil { @@ -567,18 +617,22 @@ func (a *API) StartCodespace(ctx context.Context, codespaceName string) error { } func (a *API) StopCodespace(ctx context.Context, codespaceName string, orgName string, userName string) error { - var stopURL string + var stopURL *safeurl.MutableSafeURL var spanName string + var err error if orgName != "" { - stopURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces/%s/stop", a.githubAPI, orgName, userName, codespaceName) + stopURL, err = safeurl.JoinPathWithHostPrefix(a.githubAPI, "orgs", orgName, "members", userName, "codespaces", codespaceName, "stop") spanName = "/orgs/*/members/*/codespaces/*/stop" } else { - stopURL = fmt.Sprintf("%s/user/codespaces/%s/stop", a.githubAPI, codespaceName) + stopURL, err = safeurl.JoinPathWithHostPrefix(a.githubAPI, "user", "codespaces", codespaceName, "stop") spanName = "/user/codespaces/*/stop" } + if err != nil { + return err + } - req, err := http.NewRequest(http.MethodPost, stopURL, nil) + req, err := http.NewRequest(http.MethodPost, stopURL.String(), nil) if err != nil { return fmt.Errorf("error creating request: %w", err) } @@ -605,8 +659,11 @@ type Machine struct { // GetCodespacesMachines returns the codespaces machines for the given repo, branch and location. func (a *API) GetCodespacesMachines(ctx context.Context, repoID int64, branch, location string, devcontainerPath string) ([]*Machine, error) { - reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID) - req, err := http.NewRequest(http.MethodGet, reqURL, nil) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "repositories", strconv.FormatInt(repoID, 10), "codespaces", "machines") + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -645,8 +702,11 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int64, branch, l // GetCodespacesPermissionsCheck returns a bool indicating whether the user has accepted permissions for the given repo and devcontainer path. func (a *API) GetCodespacesPermissionsCheck(ctx context.Context, repoID int64, branch string, devcontainerPath string) (bool, error) { - reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/permissions_check", a.githubAPI, repoID) - req, err := http.NewRequest(http.MethodGet, reqURL, nil) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "repositories", strconv.FormatInt(repoID, 10), "codespaces", "permissions_check") + if err != nil { + return false, err + } + req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return false, fmt.Errorf("error creating request: %w", err) } @@ -692,8 +752,11 @@ type RepoSearchParameters struct { // GetCodespaceRepoSuggestions searches for and returns repo names based on the provided search text. func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, parameters RepoSearchParameters) ([]string, error) { - reqURL := fmt.Sprintf("%s/search/repositories", a.githubAPI) - req, err := http.NewRequest(http.MethodGet, reqURL, nil) + reqURL, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "search", "repositories") + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -763,7 +826,15 @@ func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch str // GetCodespaceBillableOwner returns the billable owner and expected default values for // codespaces created by the user for a given repository. func (a *API) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*User, error) { - req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+nwo+"/codespaces/new", nil) + owner, name, err := safeurl.RepoPartsFromNWO(nwo) + if err != nil { + return nil, err + } + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "repos", owner, name, "codespaces", "new") + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -908,7 +979,11 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* return nil, fmt.Errorf("error marshaling request: %w", err) } - req, err := http.NewRequest(http.MethodPost, a.githubAPI+"/user/codespaces", bytes.NewBuffer(requestBody)) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "user", "codespaces") + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -974,18 +1049,22 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* // DeleteCodespace deletes the given codespace. func (a *API) DeleteCodespace(ctx context.Context, codespaceName string, orgName string, userName string) error { - var deleteURL string + var deleteURL *safeurl.MutableSafeURL var spanName string + var err error if orgName != "" && userName != "" { - deleteURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces/%s", a.githubAPI, orgName, userName, codespaceName) + deleteURL, err = safeurl.JoinPathWithHostPrefix(a.githubAPI, "orgs", orgName, "members", userName, "codespaces", codespaceName) spanName = "/orgs/*/members/*/codespaces/*" } else { - deleteURL = a.githubAPI + "/user/codespaces/" + codespaceName + deleteURL, err = safeurl.JoinPathWithHostPrefix(a.githubAPI, "user", "codespaces", codespaceName) spanName = "/user/codespaces/*" } + if err != nil { + return err + } - req, err := http.NewRequest(http.MethodDelete, deleteURL, nil) + req, err := http.NewRequest(http.MethodDelete, deleteURL.String(), nil) if err != nil { return fmt.Errorf("error creating request: %w", err) } @@ -1017,15 +1096,18 @@ func (a *API) ListDevContainers(ctx context.Context, repoID int64, branch string perPage = limit } - v := url.Values{} - v.Set("per_page", strconv.Itoa(perPage)) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "repositories", strconv.FormatInt(repoID, 10), "codespaces", "devcontainers") + if err != nil { + return nil, err + } + u.SetQuery("per_page", strconv.Itoa(perPage)) if branch != "" { - v.Set("ref", branch) + u.SetQuery("ref", branch) } - listURL := fmt.Sprintf("%s/repositories/%d/codespaces/devcontainers?%s", a.githubAPI, repoID, v.Encode()) + var listURL safeurl.SafeURL = u for { - req, err := http.NewRequest(http.MethodGet, listURL, nil) + req, err := http.NewRequest(http.MethodGet, listURL.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -1062,9 +1144,9 @@ func (a *API) ListDevContainers(ctx context.Context, repoID int64, branch string q := u.Query() q.Set("per_page", strconv.Itoa(newPerPage)) u.RawQuery = q.Encode() - listURL = u.String() + listURL = safeurl.NewImmutableSafeURL(u.String()) } else { - listURL = nextURL + listURL = safeurl.NewImmutableSafeURL(nextURL) } } @@ -1083,7 +1165,11 @@ func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *E return nil, fmt.Errorf("error marshaling request: %w", err) } - req, err := http.NewRequest(http.MethodPatch, a.githubAPI+"/user/codespaces/"+codespaceName, bytes.NewBuffer(requestBody)) + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "user", "codespaces", codespaceName) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPatch, u.String(), bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -1139,7 +1225,15 @@ type getCodespaceRepositoryContentsResponse struct { } func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Codespace, path string) ([]byte, error) { - req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+codespace.Repository.FullName+"/contents/"+path, nil) + owner, name, err := safeurl.RepoPartsFromNWO(codespace.Repository.FullName) + if err != nil { + return nil, err + } + u, err := safeurl.JoinPathWithHostPrefix(a.githubAPI, "repos", owner, name, "contents", path) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 88997708cca..e5bd8034faf 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -5,6 +5,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/safeurl" "github.com/hashicorp/go-version" "golang.org/x/sync/errgroup" @@ -541,7 +542,11 @@ func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Ve } apiClient := api.NewClientFromHTTP(httpClient) - err := apiClient.REST(host, "GET", "meta", nil, &metaResponse) + u, err := safeurl.JoinPath("meta") + if err != nil { + return nil, err + } + err = apiClient.REST(host, "GET", u.String(), nil, &metaResponse) if err != nil { return nil, err } diff --git a/internal/safeurl/safeurl.go b/internal/safeurl/safeurl.go new file mode 100644 index 00000000000..fccf40b9466 --- /dev/null +++ b/internal/safeurl/safeurl.go @@ -0,0 +1,164 @@ +// Package safeurl provides helpers for building REST API URL paths (and full +// URLs, when a host prefix is supplied) from variable components so that user +// or server controlled values cannot break the path or change which resource +// is addressed. +package safeurl + +import ( + "fmt" + "net/url" + "strings" +) + +// RepoPartsFromNWO parses a raw "owner/repo" string and returns the owner and name +// unescaped. It returns an error unless nwo contains exactly one slash with a non-empty +// owner and name, so a value carrying extra slashes cannot smuggle additional path +// segments through as the owner or name. +// +// This intentionally does not reuse ghrepo.FromFullName, which accepts the broader +// "[HOST/]OWNER/REPO" form. The call sites here only ever handle a bare "OWNER/REPO", +// so a stricter parse that rejects an unexpected host component is the safer fit. +func RepoPartsFromNWO(nwo string) (owner, name string, err error) { + parts := strings.Split(nwo, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("expected the \"OWNER/REPO\" format, got %q", nwo) + } + return parts[0], parts[1], nil +} + +// SafeURL is the sealed interface implemented by the URL types in this package. +// It exists so that a value known to address a safe REST API URL can be passed +// around and rendered without exposing how it was built. +type SafeURL interface { + String() string + + // The sealed method keeps the set of implementations closed to this package, + // so callers outside it cannot forge a value that claims to be safe. + sealed() +} + +// MutableSafeURL is a REST API URL built from a host prefix, path components, and query +// parameters. The path components and query parameters are URL encoded (aka +// percent-encoded) when the URL is rendered so that caller supplied values cannot +// alter the structure of the URL or change which resource it addresses; the host +// prefix is used as given. The zero value renders as the empty string. +type MutableSafeURL struct { + prefix string + components []string + query url.Values +} + +// JoinPath returns a SafeURL for the path made up of the given components. It +// returns an error if any component is exactly "..", which would traverse the URL +// path and change which resource it addresses. +func JoinPath(components ...string) (*MutableSafeURL, error) { + if err := checkTraversal(components); err != nil { + return nil, err + } + return &MutableSafeURL{components: components}, nil +} + +// JoinPathWithHostPrefix returns a SafeURL for the given host prefix and the path +// made up of the given components. It returns an error if any component is exactly +// "..", which would traverse the URL path and change which resource it addresses. +func JoinPathWithHostPrefix(hostPrefix string, components ...string) (*MutableSafeURL, error) { + if err := checkTraversal(components); err != nil { + return nil, err + } + return &MutableSafeURL{prefix: hostPrefix, components: components}, nil +} + +// checkTraversal returns an error if any component is exactly "..". Such a component +// survives percent-encoding as a real path segment and would traverse the URL path. +// A single "." is left alone because it does not traverse and is a legitimate value +// in some paths. +func checkTraversal(components []string) error { + for _, c := range components { + if c == ".." { + return fmt.Errorf("path component %q would traverse the URL path", c) + } + } + return nil +} + +func (u *MutableSafeURL) sealed() {} + +// SetQuery sets the query parameter key to value, replacing any existing value. +func (u *MutableSafeURL) SetQuery(key, value string) { + if u.query == nil { + u.query = url.Values{} + } + u.query.Set(key, value) +} + +// String renders the full URL. Path components and query parameters are URL encoded +// (aka percent-encoded) while the host prefix is included as given. The zero value +// renders as the empty string. +func (u *MutableSafeURL) String() string { + result := joinPathWithHostPrefix(u.prefix, u.components...) + if len(u.query) > 0 { + result += "?" + u.query.Encode() + } + return result +} + +// ImmutableSafeURL is a SafeURL that renders a fixed URL string verbatim. It exists +// so that a URL which was not built from percent-encoded components, such as a full +// URL returned by the server (a pagination "next" link, an asset download URL, and +// the like), can still flow through the SafeURL typed code paths. Because the stored +// value is rendered as given without any encoding, it is only safe to wrap a URL that +// was created from trusted components or received from a trusted source. +type ImmutableSafeURL struct { + url string +} + +// NewImmutableSafeURL returns an ImmutableSafeURL that renders url verbatim. Only pass +// a URL you built yourself from trusted components or received from a trusted source, +// such as a server response; this bypasses all percent-encoding, so passing a value +// that embeds unescaped user or third party input reintroduces the injection risk that +// SafeURL exists to prevent. +func NewImmutableSafeURL(url string) *ImmutableSafeURL { + return &ImmutableSafeURL{url: url} +} + +func (u *ImmutableSafeURL) sealed() {} + +// String returns the wrapped URL verbatim. +func (u *ImmutableSafeURL) String() string { + return u.url +} + +// joinPath builds a REST API URL path by percent-encoding each component with +// url.PathEscape and joining them with single slash separators. +// +// With no components, the empty string is returned. +func joinPath(components ...string) string { + // We build the path by hand rather than with url.JoinPath because url.JoinPath runs path.Clean + // on the result, which resolves any "." or ".." segments. Percent-encoding does not encode dots, + // so a component equal to "." or ".." would survive escaping and then be collapsed by the clean, + // silently changing which resource the path addresses. + escaped := make([]string, len(components)) + for i, c := range components { + escaped[i] = url.PathEscape(c) + } + return strings.Join(escaped, "/") +} + +// joinPathWithHostPrefix builds a full REST API URL by prepending hostPrefix to the path produced by +// JoinPath. A single slash is ensured at the join between hostPrefix and the path so they separate +// cleanly without doubling up. When hostPrefix is empty, the JoinPath result is returned intact, and +// when the joined path is empty, hostPrefix is returned intact. hostPrefix is used verbatim while each +// component is percent-encoded. +func joinPathWithHostPrefix(hostPrefix string, components ...string) string { + path := joinPath(components...) + if hostPrefix == "" { + return path + } + if path == "" { + return hostPrefix + } + if !strings.HasSuffix(hostPrefix, "/") { + return hostPrefix + "/" + path + } + return hostPrefix + path +} diff --git a/internal/safeurl/safeurl_test.go b/internal/safeurl/safeurl_test.go new file mode 100644 index 00000000000..41e641f4ab8 --- /dev/null +++ b/internal/safeurl/safeurl_test.go @@ -0,0 +1,317 @@ +package safeurl_test + +import ( + "testing" + + "github.com/cli/cli/v2/internal/safeurl" + "github.com/stretchr/testify/require" +) + +var _ safeurl.SafeURL = (*safeurl.MutableSafeURL)(nil) +var _ safeurl.SafeURL = (*safeurl.ImmutableSafeURL)(nil) + +func TestRepoPartsFromNWO(t *testing.T) { + + tests := []struct { + name string + nwo string + wantOwner string + wantName string + wantErr bool + }{ + { + name: "owner and repo", + nwo: "octocat/hello-world", + wantOwner: "octocat", + wantName: "hello-world", + }, + { + name: "no separator", + nwo: "octocat", + wantErr: true, + }, + { + name: "empty", + nwo: "", + wantErr: true, + }, + { + name: "missing name", + nwo: "octocat/", + wantErr: true, + }, + { + name: "missing owner", + nwo: "/hello-world", + wantErr: true, + }, + { + name: "parts are returned unescaped", + nwo: "my owner/my repo", + wantOwner: "my owner", + wantName: "my repo", + }, + { + name: "extra separators are rejected", + nwo: "foo/bar/codespaces", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + owner, name, err := safeurl.RepoPartsFromNWO(tt.nwo) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.wantOwner, owner) + require.Equal(t, tt.wantName, name) + } + }) + } +} + +func TestJoinPathRejectsTraversal(t *testing.T) { + tests := []struct { + name string + components []string + }{ + { + name: "only a .. component", + components: []string{".."}, + }, + { + name: "a .. component in the middle", + components: []string{"repos", "octocat", "..", "hello-world"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, errJoinPath := safeurl.JoinPath(tt.components...) + require.Error(t, errJoinPath) + _, errJoinPathWithHostPrefix := safeurl.JoinPathWithHostPrefix("https://api.github.com", tt.components...) + require.Error(t, errJoinPathWithHostPrefix) + }) + } +} + +func TestMutableSafeURLString(t *testing.T) { + tests := []struct { + name string + url func(t *testing.T) (*safeurl.MutableSafeURL, error) + want string + }{ + { + name: "zero value renders empty", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return &safeurl.MutableSafeURL{}, nil + }, + want: "", + }, + { + name: "path only", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPath("foo", "bar", "baz") + }, + want: "foo/bar/baz", + }, + { + name: "single path component", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPath("foo") + }, + want: "foo", + }, + { + name: "empty components produce empty segments", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPath("", "bar", "") + }, + want: "/bar/", + }, + { + name: "escapes path components", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPath("foo", "bar baz", "a/b") + }, + want: "foo/bar%20baz/a%2Fb", + }, + { + name: "pre-encoded dot-dot cannot bypass the traversal check", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPath("foo", "bar", "%2e%2e", "baz") + }, + want: "foo/bar/%252e%252e/baz", + }, + { + name: "single dot component is preserved verbatim", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPath("foo", "bar", ".", "baz") + }, + want: "foo/bar/./baz", + }, + { + name: "leading single dot components are preserved verbatim", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPath(".", ".", "foo", "bar") + }, + want: "././foo/bar", + }, + { + name: "pre-encoded dot-dot cannot bypass the traversal check with host prefix", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPathWithHostPrefix("https://host", "foo", "bar", "%2e%2e", "baz") + }, + want: "https://host/foo/bar/%252e%252e/baz", + }, + { + name: "single dot component is preserved verbatim with host prefix", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPathWithHostPrefix("https://host", "foo", "bar", ".", "baz") + }, + want: "https://host/foo/bar/./baz", + }, + { + name: "leading single dot components are preserved verbatim with host prefix", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPathWithHostPrefix("https://host", ".", ".", "foo", "bar") + }, + want: "https://host/././foo/bar", + }, + { + name: "host prefix and path", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPathWithHostPrefix("https://host", "foo", "bar", "baz") + }, + want: "https://host/foo/bar/baz", + }, + { + name: "host prefix remains intact", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPathWithHostPrefix("https://host/with/slash", "foo", "bar", "baz") + }, + want: "https://host/with/slash/foo/bar/baz", + }, + { + name: "host prefix with trailing slash", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPathWithHostPrefix("https://host/", "foo", "bar", "baz") + }, + want: "https://host/foo/bar/baz", + }, + { + name: "host prefix without path", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPathWithHostPrefix("https://host") + }, + want: "https://host", + }, + { + name: "host prefix with trailing slash and no path", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + return safeurl.JoinPathWithHostPrefix("https://host/") + }, + want: "https://host/", + }, + { + name: "query only", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + u := &safeurl.MutableSafeURL{} + u.SetQuery("page", "2") + return u, nil + }, + want: "?page=2", + }, + { + name: "path and query", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + u, err := safeurl.JoinPath("foo", "bar", "baz") + require.NoError(t, err) + u.SetQuery("value", "x") + return u, nil + }, + want: "foo/bar/baz?value=x", + }, + { + name: "host prefix, path, and query", + url: func(t *testing.T) (*safeurl.MutableSafeURL, error) { + u, err := safeurl.JoinPathWithHostPrefix("https://host", "foo", "bar") + require.NoError(t, err) + u.SetQuery("value", "x y") + return u, nil + }, + want: "https://host/foo/bar?value=x+y", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := tt.url(t) + require.NoError(t, err) + require.Equal(t, tt.want, u.String()) + }) + } +} + +func TestMutableSafeURLSetQuery(t *testing.T) { + type query struct { + key string + value string + } + + tests := []struct { + name string + queries []query + want string + }{ + { + name: "replaces existing value rather than appending", + queries: []query{{"a", "1"}, {"a", "2"}}, + want: "foo/bar?a=2", + }, + { + name: "sorts keys deterministically", + queries: []query{{"b", "2"}, {"a", "1"}}, + want: "foo/bar?a=1&b=2", + }, + { + name: "escapes keys and values", + queries: []query{{"a", "x y&z"}}, + want: "foo/bar?a=x+y%26z", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := safeurl.JoinPath("foo", "bar") + require.NoError(t, err) + for _, q := range tt.queries { + u.SetQuery(q.key, q.value) + } + require.Equal(t, tt.want, u.String()) + }) + } +} + +func TestImmutableSafeURLString(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + { + name: "empty renders empty", + url: "", + want: "", + }, + { + name: "renders the wrapped url verbatim without encoding", + url: "https://host/foo/bar baz/?value=x y", + want: "https://host/foo/bar baz/?value=x y", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, safeurl.NewImmutableSafeURL(tt.url).String()) + }) + } +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index ff6f1286e6b..694662900e6 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "net/url" "os" "path" "path/filepath" @@ -17,7 +16,9 @@ import ( "sync/atomic" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/pkg/iostreams" ) // specNamePattern matches the strict agentskills.io name spec: @@ -188,11 +189,14 @@ func parseRepoVisibility(s string) (RepoVisibility, error) { // FetchRepoVisibility returns the repository visibility: "public", "private", or "internal". func FetchRepoVisibility(client *api.Client, host, owner, repo string) (RepoVisibility, error) { - apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) + apiPath, err := safeurl.JoinPath("repos", owner, repo) + if err != nil { + return "", err + } var resp struct { Visibility string `json:"visibility"` } - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { return "", err } return parseRepoVisibility(resp.Visibility) @@ -251,11 +255,14 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res return nil, err } - commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(ref)) + commitPath, err := safeurl.JoinPath("repos", owner, repo, "commits", ref) + if err != nil { + return nil, err + } var commitResp struct { SHA string `json:"sha"` } - if err := client.REST(host, "GET", commitPath, nil, &commitResp); err == nil { + if err := client.REST(host, "GET", commitPath.String(), nil, &commitResp); err == nil { return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil } else if !isNotFound(err) { return nil, err @@ -267,25 +274,31 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res // resolveTagRef looks up a tag by short name and returns a fully qualified ref. // For annotated tags, the tag object is dereferenced to obtain the commit SHA. func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*ResolvedRef, error) { - tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(tag)) + tagPath, err := safeurl.JoinPath("repos", owner, repo, "git", "ref", fmt.Sprintf("tags/%s", tag)) + if err != nil { + return nil, err + } var refResp struct { Object struct { SHA string `json:"sha"` Type string `json:"type"` } `json:"object"` } - if err := client.REST(host, "GET", tagPath, nil, &refResp); err != nil { + if err := client.REST(host, "GET", tagPath.String(), nil, &refResp); err != nil { return nil, fmt.Errorf("tag %q not found in %s/%s: %w", tag, owner, repo, err) } sha := refResp.Object.SHA if refResp.Object.Type == "tag" { - derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) + derefPath, err := safeurl.JoinPath("repos", owner, repo, "git", "tags", sha) + if err != nil { + return nil, err + } var tagResp struct { Object struct { SHA string `json:"sha"` } `json:"object"` } - if err := client.REST(host, "GET", derefPath, nil, &tagResp); err != nil { + if err := client.REST(host, "GET", derefPath.String(), nil, &tagResp); err != nil { return nil, fmt.Errorf("could not dereference annotated tag %q: %w", tag, err) } sha = tagResp.Object.SHA @@ -295,13 +308,16 @@ func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*Resolved // resolveBranchRef looks up a branch by short name and returns a fully qualified ref. func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*ResolvedRef, error) { - refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(branch)) + refPath, err := safeurl.JoinPath("repos", owner, repo, "git", "ref", fmt.Sprintf("heads/%s", branch)) + if err != nil { + return nil, err + } var refResp struct { Object struct { SHA string `json:"sha"` } `json:"object"` } - if err := client.REST(host, "GET", refPath, nil, &refResp); err != nil { + if err := client.REST(host, "GET", refPath.String(), nil, &refResp); err != nil { return nil, fmt.Errorf("branch %q not found in %s/%s: %w", branch, owner, repo, err) } return &ResolvedRef{Ref: "refs/heads/" + branch, SHA: refResp.Object.SHA}, nil @@ -323,11 +339,14 @@ type noReleasesError struct { func (e *noReleasesError) Error() string { return e.reason } func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { - apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo)) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "releases", "latest") + if err != nil { + return nil, err + } var resp struct { TagName string `json:"tag_name"` } - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { // A 404 means the repository has no releases. This is the // only case where falling back to the default branch is safe. // Any other HTTP error (403, 500, …) or network failure is @@ -345,11 +364,14 @@ func resolveLatestRelease(client *api.Client, host, owner, repo string) (*Resolv } func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { - apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) + apiPath, err := safeurl.JoinPath("repos", owner, repo) + if err != nil { + return nil, err + } var resp struct { DefaultBranch string `json:"default_branch"` } - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { return nil, fmt.Errorf("could not determine default branch: %w", err) } branch := resp.DefaultBranch @@ -551,9 +573,13 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([] // DiscoverSkillsWithOptions finds all skills in a repository at the given // commit SHA, with configurable discovery behavior. func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA string, opts DiscoverOptions) ([]Skill, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(commitSHA)) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "git", "trees", commitSHA) + if err != nil { + return nil, err + } + apiPath.SetQuery("recursive", "true") var tree treeResponse - if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &tree); err != nil { return nil, fmt.Errorf("could not fetch repository tree: %w", err) } @@ -627,7 +653,7 @@ func fetchDescription(client *api.Client, host, owner, repo string, skill *Skill if err != nil { return "" } - result, err := frontmatter.Parse(content) + result, err := frontmatter.Parse(content.Raw()) if err != nil { return "" } @@ -697,7 +723,11 @@ func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commi } parentPath := path.Dir(skillPath) - apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(parentPath), commitSHA) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "contents", parentPath) + if err != nil { + return nil, err + } + apiPath.SetQuery("ref", commitSHA) var contents []struct { Name string `json:"name"` @@ -705,7 +735,7 @@ func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commi SHA string `json:"sha"` Type string `json:"type"` } - if err := client.REST(host, "GET", apiPath, nil, &contents); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &contents); err != nil { return nil, fmt.Errorf("path %q not found in %s/%s: %w", parentPath, owner, repo, err) } @@ -720,9 +750,12 @@ func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commi return nil, fmt.Errorf("skill directory %q not found in %s/%s", skillPath, owner, repo) } - skillTreePath := fmt.Sprintf("repos/%s/%s/git/trees/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA)) + skillTreePath, err := safeurl.JoinPath("repos", owner, repo, "git", "trees", treeSHA) + if err != nil { + return nil, err + } var skillTree treeResponse - if err := client.REST(host, "GET", skillTreePath, nil, &skillTree); err != nil { + if err := client.REST(host, "GET", skillTreePath.String(), nil, &skillTree); err != nil { return nil, fmt.Errorf("could not read skill directory: %w", err) } @@ -778,9 +811,13 @@ func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commi // DiscoverSkillFiles returns all file paths belonging to a skill directory // by fetching the skill's subtree directly using its tree SHA. func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPath string) ([]SkillFile, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA)) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "git", "trees", treeSHA) + if err != nil { + return nil, err + } + apiPath.SetQuery("recursive", "true") var tree treeResponse - if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &tree); err != nil { return nil, fmt.Errorf("could not fetch skill tree: %w", err) } @@ -806,9 +843,13 @@ func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPat // ListSkillFiles returns all files in a skill directory as public SkillFile // structs with paths relative to the skill root. func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]SkillFile, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA)) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "git", "trees", treeSHA) + if err != nil { + return nil, err + } + apiPath.SetQuery("recursive", "true") var tree treeResponse - if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &tree); err != nil { return nil, fmt.Errorf("could not fetch skill tree: %w", err) } @@ -841,9 +882,12 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth i if depth > maxTreeDepth { return nil, fmt.Errorf("tree depth exceeds %d levels at %s", maxTreeDepth, prefix) } - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "git", "trees", sha) + if err != nil { + return nil, err + } var tree treeResponse - if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &tree); err != nil { return nil, fmt.Errorf("could not fetch tree %s: %w", prefix, err) } @@ -867,30 +911,36 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth i return files, nil } -// FetchBlob retrieves the content of a blob by SHA. -func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) +// FetchBlob retrieves the content of a blob by SHA. The blob is base64-encoded +// inside the JSON response and decoded here, so it is returned as +// iostreams.Untrusted and callers must choose sanitized display or raw +// round-tripping. +func FetchBlob(client *api.Client, host, owner, repo, sha string) (iostreams.Untrusted, error) { + apiPath, err := safeurl.JoinPath("repos", owner, repo, "git", "blobs", sha) + if err != nil { + return iostreams.Untrusted{}, err + } var resp struct { SHA string `json:"sha"` Content string `json:"content"` Encoding string `json:"encoding"` } - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { - return "", fmt.Errorf("could not fetch blob: %w", err) + if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { + return iostreams.Untrusted{}, fmt.Errorf("could not fetch blob: %w", err) } if resp.Encoding != "base64" { - return "", fmt.Errorf("unexpected blob encoding: %s", resp.Encoding) + return iostreams.Untrusted{}, fmt.Errorf("unexpected blob encoding: %s", resp.Encoding) } // GitHub API returns base64 with embedded newlines; use the StdEncoding // decoder via a reader to handle them transparently. decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(resp.Content))) if err != nil { - return "", fmt.Errorf("could not decode blob content: %w", err) + return iostreams.Untrusted{}, fmt.Errorf("could not decode blob content: %w", err) } - return string(decoded), nil + return iostreams.NewUntrustedBytes(decoded), nil } // DiscoverLocalSkills finds non-hidden-dir skills in a local directory using diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 8d1cff8c93e..cc7c35104a7 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -431,7 +431,7 @@ func TestResolveRef(t *testing.T) { version: "main", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fmain"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "branch-sha"}, })) @@ -444,10 +444,10 @@ func TestResolveRef(t *testing.T) { version: "v1.0", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v1.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fv1.0"), httpmock.StatusStringResponse(404, "not found")) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv1.0"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "abc123", "type": "commit"}, })) @@ -460,10 +460,10 @@ func TestResolveRef(t *testing.T) { version: "v2.0", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v2.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fv2.0"), httpmock.StatusStringResponse(404, "not found")) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v2.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv2.0"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, })) @@ -481,10 +481,10 @@ func TestResolveRef(t *testing.T) { version: "deadbeef", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/deadbeef"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fdeadbeef"), httpmock.StatusStringResponse(404, "not found")) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/deadbeef"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fdeadbeef"), httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/deadbeef"), @@ -498,10 +498,10 @@ func TestResolveRef(t *testing.T) { version: "nonexistent", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fnonexistent"), httpmock.StatusStringResponse(404, "not found")) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fnonexistent"), httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/nonexistent"), @@ -514,7 +514,7 @@ func TestResolveRef(t *testing.T) { version: "release", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/release"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Frelease"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "branch-sha"}, })) @@ -528,7 +528,7 @@ func TestResolveRef(t *testing.T) { version: "refs/tags/v1.0", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv1.0"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "tag-sha", "type": "commit"}, })) @@ -541,7 +541,7 @@ func TestResolveRef(t *testing.T) { version: "refs/heads/feature", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/feature"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Ffeature"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "feature-sha"}, })) @@ -554,7 +554,7 @@ func TestResolveRef(t *testing.T) { version: "refs/tags/nonexistent", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fnonexistent"), httpmock.StatusStringResponse(404, "not found")) }, wantErr: `tag "nonexistent" not found in monalisa/octocat-skills`, @@ -564,7 +564,7 @@ func TestResolveRef(t *testing.T) { version: "refs/heads/nonexistent", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fnonexistent"), httpmock.StatusStringResponse(404, "not found")) }, wantErr: `branch "nonexistent" not found in monalisa/octocat-skills`, @@ -576,7 +576,7 @@ func TestResolveRef(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), httpmock.JSONResponse(map[string]interface{}{"tag_name": "v3.0"})) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "release-sha", "type": "commit"}, })) @@ -594,7 +594,7 @@ func TestResolveRef(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills"), httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fmain"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "branch-sha"}, })) @@ -607,7 +607,7 @@ func TestResolveRef(t *testing.T) { version: "refs/tags/v4.0", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v4.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv4.0"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, })) @@ -645,7 +645,7 @@ func TestResolveRef(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills"), httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fmain"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "fallback-sha"}, })) @@ -670,7 +670,7 @@ func TestResolveRef(t *testing.T) { version: "main", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fmain"), httpmock.StatusStringResponse(500, "server error")) }, wantErr: `branch "main" not found in monalisa/octocat-skills`, @@ -680,7 +680,7 @@ func TestResolveRef(t *testing.T) { version: "develop", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/develop"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fdevelop"), httpmock.StatusStringResponse(403, "forbidden")) }, wantErr: `branch "develop" not found in monalisa/octocat-skills`, @@ -690,10 +690,10 @@ func TestResolveRef(t *testing.T) { version: "v5.0", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v5.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads%2Fv5.0"), httpmock.StatusStringResponse(404, "not found")) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v5.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv5.0"), httpmock.StatusStringResponse(500, "server error")) }, wantErr: `tag "v5.0" not found in monalisa/octocat-skills`, @@ -772,7 +772,7 @@ func TestFetchBlob(t *testing.T) { return } require.NoError(t, err) - assert.Equal(t, tt.want, got) + assert.Equal(t, tt.want, got.Raw()) }) } } diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 005681cac54..a0b0bfb708f 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -266,11 +266,15 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { } for _, file := range files { - content, err := discovery.FetchBlob(opts.Client, opts.Host, opts.Owner, opts.Repo, file.SHA) + fetchedContent, err := discovery.FetchBlob(opts.Client, opts.Host, opts.Owner, opts.Repo, file.SHA) if err != nil { return fmt.Errorf("could not fetch %s: %w", file.Path, err) } + // Install path: the blob is written to disk verbatim, so the raw bytes + // must be preserved. + content := fetchedContent.Raw() + relPath := strings.TrimPrefix(file.Path, skill.Path+"/") safeDest, err := safeSkillDir.Join(relPath) diff --git a/internal/update/update.go b/internal/update/update.go index 20cd09606c8..27a7d8a248f 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -14,6 +14,7 @@ import ( "time" "github.com/cli/cli/v2/internal/ci" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/extensions" "github.com/hashicorp/go-version" "github.com/mattn/go-isatty" @@ -112,7 +113,15 @@ func CheckForUpdate(ctx context.Context, client *http.Client, stateFilePath, rep } func getLatestReleaseInfo(ctx context.Context, client *http.Client, repo string) (*ReleaseInfo, error) { - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo), nil) + owner, name, err := safeurl.RepoPartsFromNWO(repo) + if err != nil { + return nil, err + } + u, err := safeurl.JoinPathWithHostPrefix("https://api.github.com", "repos", owner, name, "releases", "latest") + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) if err != nil { return nil, err } diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index 551e7c9ae31..eda3106819d 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -8,8 +8,9 @@ import ( "fmt" "io" "net/http" - "net/url" "time" + + "github.com/cli/cli/v2/internal/safeurl" ) const defaultEventType = "gh_cli" @@ -66,7 +67,10 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen return nil, errors.New("problem statement is required") } - url := fmt.Sprintf("%s/%s/%s", c.jobsBasePathV1(), url.PathEscape(owner), url.PathEscape(repo)) + u, err := safeurl.JoinPathWithHostPrefix(c.jobsBasePathV1(), owner, repo) + if err != nil { + return nil, err + } prOpts := JobPullRequest{} if baseBranch != "" { @@ -82,7 +86,7 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen b, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(b)) if err != nil { return nil, err } @@ -132,8 +136,11 @@ func (c *CAPIClient) GetJob(ctx context.Context, owner, repo, jobID string) (*Jo if owner == "" || repo == "" || jobID == "" { return nil, errors.New("owner, repo, and jobID are required") } - url := fmt.Sprintf("%s/%s/%s/%s", c.jobsBasePathV1(), url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(jobID)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + u, err := safeurl.JoinPathWithHostPrefix(c.jobsBasePathV1(), owner, repo, jobID) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody) if err != nil { return nil, err } diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 69ea80820c5..9a9d164189e 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -10,12 +10,12 @@ import ( "io" "math" "net/http" - "net/url" "slices" "strconv" "time" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/safeurl" "github.com/shurcooL/githubv4" "github.com/vmihailenco/msgpack/v5" ) @@ -217,16 +217,16 @@ func (c *CAPIClient) ListLatestSessionsForViewer(ctx context.Context, limit int) return nil, nil } - sessionsURL, err := url.JoinPath(c.capiBaseURL, "agents", "sessions") + sessionsURL, err := safeurl.JoinPathWithHostPrefix(c.capiBaseURL, "agents", "sessions") if err != nil { - return nil, fmt.Errorf("failed to build sessions URL: %w", err) + return nil, err } pageSize := defaultSessionsPerPage seenResources := make(map[int64]struct{}) latestSessions := make([]session, 0, limit) for page := 1; ; page++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, sessionsURL, http.NoBody) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sessionsURL.String(), http.NoBody) if err != nil { return nil, err } @@ -299,9 +299,12 @@ func (c *CAPIClient) GetSession(ctx context.Context, id string) (*Session, error return nil, fmt.Errorf("missing session ID") } - url := fmt.Sprintf("%s/agents/sessions/%s", c.capiBaseURL, url.PathEscape(id)) + u, err := safeurl.JoinPathWithHostPrefix(c.capiBaseURL, "agents", "sessions", id) + if err != nil { + return nil, err + } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody) if err != nil { return nil, err } @@ -338,9 +341,12 @@ func (c *CAPIClient) GetSessionLogs(ctx context.Context, id string) ([]byte, err return nil, fmt.Errorf("missing session ID") } - url := fmt.Sprintf("%s/agents/sessions/%s/logs", c.capiBaseURL, url.PathEscape(id)) + u, err := safeurl.JoinPathWithHostPrefix(c.capiBaseURL, "agents", "sessions", id, "logs") + if err != nil { + return nil, err + } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody) if err != nil { return nil, err } @@ -371,9 +377,12 @@ func (c *CAPIClient) ListSessionsByResourceID(ctx context.Context, resourceType return nil, nil } - url := fmt.Sprintf("%s/agents/resource/%s/%d", c.capiBaseURL, url.PathEscape(resourceType), resourceID) + u, err := safeurl.JoinPathWithHostPrefix(c.capiBaseURL, "agents", "resource", resourceType, strconv.FormatInt(resourceID, 10)) + if err != nil { + return nil, err + } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody) if err != nil { return nil, err } diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index c94f5e603de..57cb5dc4b36 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -97,9 +97,9 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } if len(choice.Delta.ToolCalls) == 0 { - if choice.Delta.Content != "" && choice.Delta.Role == "assistant" { + if !choice.Delta.Content.Empty() && choice.Delta.Role == "assistant" { // Copilot message and we should display. - renderRawMarkdown(choice.Delta.Content, w, io) + renderRawMarkdown(choice.Delta.Content.String(), w, io) } continue } @@ -107,14 +107,14 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I // Since we don't want to clear-and-reprint live progress of events, we // need to only process entries that correspond to a finished tool call. // Such entries have a non-empty Content field. - if choice.Delta.Content == "" { + if choice.Delta.Content.Empty() { continue } - if choice.Delta.ReasoningText != "" { + if !choice.Delta.ReasoningText.Empty() { // Note that this should be formatted as a normal "thought" message, // without the heading. - renderRawMarkdown(choice.Delta.ReasoningText, w, io) + renderRawMarkdown(choice.Delta.ReasoningText.String(), w, io) } for _, tc := range choice.Delta.ToolCalls { @@ -139,7 +139,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } renderToolCallTitle(w, cs, fmt.Sprintf("View %s", cs.Bold(relativeFilePath(args.Path))), "") - content := stripDiffFormat(choice.Delta.Content) + content := stripDiffFormat(choice.Delta.Content.String()) if err := renderFileContentAsMarkdown(args.Path, content, w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render viewed file content: %v\n\n", err) @@ -153,9 +153,9 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I renderToolCallTitle(w, cs, "Run Bash command", "") } - contentWithCommand := choice.Delta.Content + contentWithCommand := choice.Delta.Content.String() if v.Command != "" { - contentWithCommand = fmt.Sprintf("$ %s\n%s", v.Command, choice.Delta.Content) + contentWithCommand = fmt.Sprintf("$ %s\n%s", v.Command, choice.Delta.Content.String()) } if err := renderFileContentAsMarkdown("commands.sh", contentWithCommand, w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render bash command output: %v\n\n", err) @@ -220,9 +220,9 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } // TODO: KW I wasn't able to get this case to populate ever. - if choice.Delta.Content != "" { + if !choice.Delta.Content.Empty() { // Try to treat this as JSON - if err := renderContentAsJSONMarkdown("", choice.Delta.Content, w, io); err != nil { + if err := renderContentAsJSONMarkdown("", choice.Delta.Content.String(), w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render progress update content: %v\n", err) } } @@ -247,9 +247,9 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } renderToolCallTitle(w, cs, "Edit", cs.Bold(relativeFilePath(args.Path))) - if err := renderFileContentAsMarkdown("output.diff", choice.Delta.Content, w, io); err != nil { + if err := renderFileContentAsMarkdown("output.diff", choice.Delta.Content.String(), w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render str_replace diff: %v\n\n", err) - fmt.Fprintln(io.ErrOut, choice.Delta.Content) + fmt.Fprintln(io.ErrOut, choice.Delta.Content.String()) } default: // Unknown tool call. For example for "codeql_checker": @@ -257,7 +257,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I renderGenericToolCall(w, cs, name) // If it's JSON, treat it as such, otherwise we skip whatever the content is. - _ = renderContentAsJSONMarkdown("Output:", choice.Delta.Content, w, io) + _ = renderContentAsJSONMarkdown("Output:", choice.Delta.Content.String(), w, io) // The entirety of the args can be treated as "input" to the tool call. // We try to render it as JSON, but if that fails, just skip it. @@ -500,9 +500,9 @@ type chatCompletionChunkEntry struct { Object string `json:"object"` Choices []struct { Delta struct { - ReasoningText string `json:"reasoning_text"` - Content string `json:"content"` - Role string `json:"role"` + ReasoningText iostreams.Untrusted `json:"reasoning_text"` + Content iostreams.Untrusted `json:"content"` + Role string `json:"role"` ToolCalls []struct { Function struct { Name string `json:"name"` diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 4a87e0f8cb9..5b85f987ca4 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -59,6 +59,8 @@ type ApiOptions struct { CacheTTL time.Duration FilterOutput string Verbose bool + + AllowEscapeSequences bool } func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command { @@ -298,6 +300,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command cmd.Flags().StringVarP(&opts.FilterOutput, "jq", "q", "", "Query to select values from the response using jq syntax") cmd.Flags().DurationVar(&opts.CacheTTL, "cache", 0, "Cache the response, e.g. \"3600s\", \"60m\", \"1h\"") cmd.Flags().BoolVar(&opts.Verbose, "verbose", false, "Include full HTTP request and response in the output") + cmd.Flags().BoolVar(&opts.AllowEscapeSequences, "allow-escape-sequences", false, "Allow printing terminal escape sequences") return cmd } @@ -331,7 +334,13 @@ func apiRun(opts *ApiOptions) error { } } - var bodyWriter io.Writer = opts.IO.Out + // Response content funnels through ContentOut. It stays in passthrough here: + // JSON is sanitized by the transport and the jq/template/jsoncolor paths emit + // our own formatting, so only a raw non-JSON body needs neutralizing, done at + // its copy below. + opts.IO.SetContentSanitization(false) + + var bodyWriter io.Writer = opts.IO.ContentOut var headersWriter io.Writer = opts.IO.Out if opts.Silent { bodyWriter = io.Discard @@ -518,7 +527,20 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW isLastPage: isLastPage, } } - _, err = io.Copy(bodyWriter, responseBody) + // A raw non-JSON body is the only response the transport does not sanitize. + // It is faithful byte output, so binary bound for a terminal and text + // carrying escape sequences are refused; the opt-out flag and discarded + // output stream verbatim. + if !isJSON && !opts.AllowEscapeSequences && bodyWriter != io.Discard { + err = iostreams.CopyGuardedContent(bodyWriter, responseBody, opts.IO.IsStdoutTTY()) + if binErr, ok := errors.AsType[iostreams.BinaryTerminalError](err); ok { + err = fmt.Errorf("%w; redirect or pipe stdout to save it, or pass --allow-escape-sequences to output it anyway", binErr) + } else if errors.Is(err, iostreams.ErrEscapeSequence) { + err = errors.New("the response contains terminal escape sequences; pass --allow-escape-sequences to output it anyway") + } + } else { + _, err = io.Copy(bodyWriter, responseBody) + } } if err != nil { return diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index bc29b1eb73c..33a45543579 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -428,6 +428,7 @@ func Test_apiRun(t *testing.T) { options ApiOptions httpResponse *http.Response err error + errMsg string stdout string stderr string isatty bool @@ -656,6 +657,81 @@ func Test_apiRun(t *testing.T) { stderr: ``, isatty: true, }, + { + name: "refuses escape sequences in non-JSON body on a TTY", + httpResponse: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("\x1b[31mred\x1b[m")), + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + errMsg: "the response contains terminal escape sequences; pass --allow-escape-sequences to output it anyway", + stdout: ``, + stderr: ``, + isatty: true, + }, + { + name: "passes escape sequences through with --allow-escape-sequences on a TTY", + options: ApiOptions{ + AllowEscapeSequences: true, + }, + httpResponse: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("\x1b[31mred\x1b[m")), + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + err: nil, + stdout: "\x1b[31mred\x1b[m", + stderr: ``, + isatty: true, + }, + { + name: "refuses escape sequences in non-JSON body when piped", + httpResponse: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("\x1b[31mred\x1b[m")), + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + errMsg: "the response contains terminal escape sequences; pass --allow-escape-sequences to output it anyway", + stdout: ``, + stderr: ``, + isatty: false, + }, + { + name: "outputs clean non-JSON text on a TTY", + httpResponse: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("plain readme text\n")), + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + err: nil, + stdout: "plain readme text\n", + stderr: ``, + isatty: true, + }, + { + name: "streams binary non-JSON body when piped", + httpResponse: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...))), + Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }, + err: nil, + stdout: string(append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...)), + stderr: ``, + isatty: false, + }, + { + name: "refuses binary non-JSON body on a TTY", + httpResponse: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...))), + Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }, + errMsg: "refusing to output binary content (image/png) to the terminal; redirect or pipe stdout to save it, or pass --allow-escape-sequences to output it anyway", + stdout: ``, + stderr: ``, + isatty: true, + }, } for _, tt := range tests { @@ -675,7 +751,11 @@ func Test_apiRun(t *testing.T) { } err := apiRun(&tt.options) - if err != tt.err { + if tt.errMsg != "" { + if err == nil || err.Error() != tt.errMsg { + t.Errorf("expected error %q, got %v", tt.errMsg, err) + } + } else if err != tt.err { t.Errorf("expected error %v, got %v", tt.err, err) } diff --git a/pkg/cmd/api/http.go b/pkg/cmd/api/http.go index b1b503cc2ce..337a07b7d0a 100644 --- a/pkg/cmd/api/http.go +++ b/pkg/cmd/api/http.go @@ -21,6 +21,8 @@ func httpRequest(client *http.Client, hostname string, method string, p string, } else if isGraphQL { requestURL = ghinstance.GraphQLEndpoint(hostname) } else { + // Note that the gh api command takes the path verbatim from the user, so we + // intentionally do not route it through safeurl and do not escape it here. requestURL = ghinstance.RESTPrefix(hostname) + strings.TrimPrefix(p, "/") } diff --git a/pkg/cmd/attestation/api/attestation.go b/pkg/cmd/attestation/api/attestation.go index 68ccff77e64..fc699d7d94c 100644 --- a/pkg/cmd/attestation/api/attestation.go +++ b/pkg/cmd/attestation/api/attestation.go @@ -8,11 +8,6 @@ import ( "github.com/sigstore/sigstore-go/pkg/bundle" ) -const ( - GetAttestationByRepoAndSubjectDigestPath = "repos/%s/attestations/%s" - GetAttestationByOwnerAndSubjectDigestPath = "orgs/%s/attestations/%s" -) - var ErrNoAttestationsFound = errors.New("no attestations found") type Attestation struct { diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index 41c713d7c0c..0cb3a5a1e81 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -5,12 +5,13 @@ import ( "fmt" "io" "net/http" - neturl "net/url" + "strconv" "strings" "time" "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/safeurl" ioconfig "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/klauspost/compress/snappy" v1 "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" @@ -100,18 +101,29 @@ func (c *LiveClient) GetByDigest(params FetchParams) ([]*Attestation, error) { return bundles, nil } -func (c *LiveClient) buildRequestURL(params FetchParams) (string, error) { +func (c *LiveClient) buildRequestURL(params FetchParams) (safeurl.SafeURL, error) { if err := params.Validate(); err != nil { - return "", err + return nil, err } - var url string + var u *safeurl.MutableSafeURL if params.Repo != "" { // check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo. // If Repo is not set, the field will remain empty. It will not be populated using the value of Owner. - url = fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, params.Repo, params.Digest) + owner, name, err := safeurl.RepoPartsFromNWO(params.Repo) + if err != nil { + return nil, err + } + u, err = safeurl.JoinPath("repos", owner, name, "attestations", params.Digest) + if err != nil { + return nil, err + } } else { - url = fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, params.Owner, params.Digest) + var err error + u, err = safeurl.JoinPath("orgs", params.Owner, "attestations", params.Digest) + if err != nil { + return nil, err + } } perPage := params.Limit @@ -120,15 +132,15 @@ func (c *LiveClient) buildRequestURL(params FetchParams) (string, error) { } // ref: https://github.com/cli/go-gh/blob/d32c104a9a25c9de3d7c7b07a43ae0091441c858/example_gh_test.go#L96 - url = fmt.Sprintf("%s?per_page=%d", url, perPage) + u.SetQuery("per_page", strconv.Itoa(perPage)) if params.PredicateType != "" { - url = fmt.Sprintf("%s&predicate_type=%s", url, neturl.QueryEscape(params.PredicateType)) + u.SetQuery("predicate_type", params.PredicateType) } - return url, nil + return u, nil } func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) { - url, err := c.buildRequestURL(params) + u, err := c.buildRequestURL(params) if err != nil { return nil, err } @@ -137,10 +149,12 @@ func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) var resp AttestationsResponse bo := backoff.NewConstantBackOff(getAttestationRetryInterval) + var pageURL safeurl.SafeURL = u + // if no attestation or less than limit, then keep fetching - for url != "" && len(attestations) < params.Limit { + for pageURL.String() != "" && len(attestations) < params.Limit { err := backoff.Retry(func() error { - newURL, restErr := c.githubAPI.RESTWithNext(c.host, http.MethodGet, url, nil, &resp) + newURL, restErr := c.githubAPI.RESTWithNext(c.host, http.MethodGet, pageURL.String(), nil, &resp) if restErr != nil { if shouldRetry(restErr) { return restErr @@ -148,7 +162,7 @@ func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) return backoff.Permanent(restErr) } - url = newURL + pageURL = safeurl.NewImmutableSafeURL(newURL) // filter by the initiator type if params.Initiator != "" { @@ -201,7 +215,7 @@ func (c *LiveClient) fetchBundleFromAttestations(attestations []*Attestation) ([ } // otherwise fetch the bundle with the provided URL - b, err := c.getBundle(a.BundleURL) + b, err := c.getBundle(safeurl.NewImmutableSafeURL(a.BundleURL)) if err != nil { return fmt.Errorf("failed to fetch bundle with URL: %w", err) } @@ -220,19 +234,19 @@ func (c *LiveClient) fetchBundleFromAttestations(attestations []*Attestation) ([ return fetched, nil } -func (c *LiveClient) getBundle(url string) (*bundle.Bundle, error) { +func (c *LiveClient) getBundle(url safeurl.SafeURL) (*bundle.Bundle, error) { c.logger.VerbosePrintf("Fetching attestation bundle with bundle URL\n\n") var sgBundle *bundle.Bundle bo := backoff.NewConstantBackOff(getAttestationRetryInterval) err := backoff.Retry(func() error { - resp, err := c.externalHttpClient.Get(url) + resp, err := c.externalHttpClient.Get(url.String()) if err != nil { return fmt.Errorf("request to fetch bundle from URL failed: %w", err) } if resp.StatusCode >= 500 && resp.StatusCode <= 599 { - return fmt.Errorf("attestation bundle with URL %s returned status code %d", url, resp.StatusCode) + return fmt.Errorf("attestation bundle with URL %s returned status code %d", url.String(), resp.StatusCode) } defer resp.Body.Close() @@ -279,15 +293,19 @@ func shouldRetry(err error) bool { // GetTrustDomain returns the current trust domain. If the default is used // the empty string is returned func (c *LiveClient) GetTrustDomain() (string, error) { - return c.getTrustDomain(MetaPath) + u, err := safeurl.JoinPath(MetaPath) + if err != nil { + return "", err + } + return c.getTrustDomain(u) } -func (c *LiveClient) getTrustDomain(url string) (string, error) { +func (c *LiveClient) getTrustDomain(u safeurl.SafeURL) (string, error) { var resp MetaResponse bo := backoff.NewConstantBackOff(getAttestationRetryInterval) err := backoff.Retry(func() error { - restErr := c.githubAPI.REST(c.host, http.MethodGet, url, nil, &resp) + restErr := c.githubAPI.REST(c.host, http.MethodGet, u.String(), nil, &resp) if restErr != nil { if shouldRetry(restErr) { return restErr diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index e27297b51d2..9f96be3448e 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -3,6 +3,7 @@ package api import ( "testing" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" "github.com/stretchr/testify/require" @@ -261,7 +262,7 @@ func TestGetBundle(t *testing.T) { logger: io.NewTestHandler(), } - b, err := c.getBundle("https://mybundleurl.com") + b, err := c.getBundle(safeurl.NewImmutableSafeURL("https://mybundleurl.com")) require.NoError(t, err) require.Equal(t, "application/vnd.dev.sigstore.bundle.v0.3+json", b.GetMediaType()) mockHTTPClient.AssertNumberOfCalls(t, "OnGetSuccess", 1) @@ -280,7 +281,7 @@ func TestGetBundle_SuccessfulRetry(t *testing.T) { logger: io.NewTestHandler(), } - b, err := c.getBundle("mybundleurl") + b, err := c.getBundle(safeurl.NewImmutableSafeURL("mybundleurl")) require.NoError(t, err) require.Equal(t, "application/vnd.dev.sigstore.bundle.v0.3+json", b.GetMediaType()) mockHTTPClient.AssertNumberOfCalls(t, "OnGetFailAfterNCalls", 2) @@ -294,7 +295,7 @@ func TestGetBundle_PermanentBackoffFail(t *testing.T) { logger: io.NewTestHandler(), } - b, err := c.getBundle("mybundleurl") + b, err := c.getBundle(safeurl.NewImmutableSafeURL("mybundleurl")) // var permanent *backoff.PermanentError //require.IsType(t, &backoff.PermanentError{}, err) require.Error(t, err) @@ -311,7 +312,7 @@ func TestGetBundle_RequestFail(t *testing.T) { logger: io.NewTestHandler(), } - b, err := c.getBundle("mybundleurl") + b, err := c.getBundle(safeurl.NewImmutableSafeURL("mybundleurl")) require.Error(t, err) require.Nil(t, b) mockHTTPClient.AssertNumberOfCalls(t, "OnGetReqFail", 4) diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go index 1d1595eca70..9f15653686e 100644 --- a/pkg/cmd/attestation/verify/policy.go +++ b/pkg/cmd/attestation/verify/policy.go @@ -24,7 +24,7 @@ func expandToGitHubURL(tenant, ownerOrRepo string) string { func expandToGitHubURLRegex(tenant, ownerOrRepo string) string { url := expandToGitHubURL(tenant, ownerOrRepo) - return fmt.Sprintf("(?i)^%s/", url) + return fmt.Sprintf("(?i)^%s", regexp.QuoteMeta(url+"/")) } func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, error) { @@ -155,7 +155,7 @@ func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { } if match { - return fmt.Sprintf("^https://%s", signerWorkflow), nil + return "^" + regexp.QuoteMeta(fmt.Sprintf("https://%s", signerWorkflow)), nil } // if the provided workflow did not match the expect format @@ -164,5 +164,5 @@ func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { return "", errors.New("unknown signer workflow host") } - return fmt.Sprintf("^https://%s/%s", hostname, signerWorkflow), nil + return "^" + regexp.QuoteMeta(fmt.Sprintf("https://%s/%s", hostname, signerWorkflow)), nil } diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index ff10cad11d7..ae6e022f05f 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -1,6 +1,7 @@ package verify import ( + "regexp" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" @@ -39,7 +40,7 @@ func TestNewEnforcementCriteria(t *testing.T) { c, err := newEnforcementCriteria(opts) require.NoError(t, err) - require.Equal(t, "(?i)^https://github.com/foo/bar/", c.SANRegex) + require.Equal(t, `(?i)^https://github\.com/foo/bar/`, c.SANRegex) require.Zero(t, c.SAN) }) @@ -55,7 +56,7 @@ func TestNewEnforcementCriteria(t *testing.T) { c, err := newEnforcementCriteria(opts) require.NoError(t, err) - require.Equal(t, "(?i)^https://baz.ghe.com/foo/bar/", c.SANRegex) + require.Equal(t, `(?i)^https://baz\.ghe\.com/foo/bar/`, c.SANRegex) require.Zero(t, c.SAN) }) @@ -70,7 +71,7 @@ func TestNewEnforcementCriteria(t *testing.T) { c, err := newEnforcementCriteria(opts) require.NoError(t, err) - require.Equal(t, "^https://github.com/foo/bar/.github/workflows/attest.yml", c.SANRegex) + require.Equal(t, `^https://github\.com/foo/bar/\.github/workflows/attest\.yml`, c.SANRegex) require.Zero(t, c.SAN) }) @@ -83,7 +84,7 @@ func TestNewEnforcementCriteria(t *testing.T) { c, err := newEnforcementCriteria(opts) require.NoError(t, err) - require.Equal(t, "(?i)^https://github.com/foo/bar/", c.SANRegex) + require.Equal(t, `(?i)^https://github\.com/foo/bar/`, c.SANRegex) }) t.Run("sets SANRegex using opts.Owner", func(t *testing.T) { @@ -94,7 +95,23 @@ func TestNewEnforcementCriteria(t *testing.T) { c, err := newEnforcementCriteria(opts) require.NoError(t, err) - require.Equal(t, "(?i)^https://github.com/foo/", c.SANRegex) + require.Equal(t, `(?i)^https://github\.com/foo/`, c.SANRegex) + }) + + t.Run("SANRegex escapes regex metacharacters in repo names", func(t *testing.T) { + opts := &Options{ + ArtifactPath: artifactPath, + SignerRepo: "my.org/my.repo", + } + + c, err := newEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, `(?i)^https://github\.com/my\.org/my\.repo/`, c.SANRegex) + + // Verify the generated regex does NOT match a lookalike repo + re := regexp.MustCompile(c.SANRegex) + require.True(t, re.MatchString("https://github.com/my.org/my.repo/.github/workflows/build.yml")) + require.False(t, re.MatchString("https://github.com/myXorg/myXrepo/.github/workflows/build.yml")) }) t.Run("sets Extensions.RunnerEnvironment to GitHubRunner value if opts.DenySelfHostedRunner is true", func(t *testing.T) { @@ -280,25 +297,25 @@ func TestValidateSignerWorkflow(t *testing.T) { { name: "workflow with default host", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: "^https://github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", + expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml`, host: "github.com", }, { name: "workflow with workflow URL included", providedSignerWorkflow: "github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: "^https://github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", + expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml`, host: "github.com", }, { name: "workflow with GH_HOST set", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: "^https://myhost.github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", + expectedWorkflowRegex: `^https://myhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml`, host: "myhost.github.com", }, { name: "workflow with authenticated host", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: "^https://authedhost.github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", + expectedWorkflowRegex: `^https://authedhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml`, host: "authedhost.github.com", }, } diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index cd018430d49..c76dc5fb84c 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/ssh" @@ -258,8 +259,11 @@ func GetCurrentLogin(httpClient httpClient, hostname, authToken string) (string, result := struct { Data struct{ Viewer struct{ Login string } } }{} - apiEndpoint := ghinstance.GraphQLEndpoint(hostname) - req, err := http.NewRequest("POST", apiEndpoint, bytes.NewBuffer(reqBody)) + apiEndpoint, err := safeurl.JoinPathWithHostPrefix(ghinstance.GraphQLEndpoint(hostname)) + if err != nil { + return "", err + } + req, err := http.NewRequest("POST", apiEndpoint.String(), bytes.NewBuffer(reqBody)) if err != nil { return "", err } diff --git a/pkg/cmd/auth/shared/oauth_scopes.go b/pkg/cmd/auth/shared/oauth_scopes.go index 8d9996019b8..bc5e611163a 100644 --- a/pkg/cmd/auth/shared/oauth_scopes.go +++ b/pkg/cmd/auth/shared/oauth_scopes.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" ) type MissingScopesError struct { @@ -33,9 +34,12 @@ type httpClient interface { // GetScopes performs a GitHub API request and returns the value of the X-Oauth-Scopes header. func GetScopes(httpClient httpClient, hostname, authToken string) (string, error) { - apiEndpoint := ghinstance.RESTPrefix(hostname) + apiEndpoint, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(hostname)) + if err != nil { + return "", err + } - req, err := http.NewRequest("GET", apiEndpoint, nil) + req, err := http.NewRequest("GET", apiEndpoint.String(), nil) if err != nil { return "", err } diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 658a8d8bc79..f1c597bbac9 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -329,10 +329,17 @@ func statusRun(opts *StatusOptions) error { return finalErr } +// knownTokenPrefixes contains GitHub's token format prefixes. +// See [GitHub token formats]. +// +// [GitHub token formats]: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github#githubs-token-formats +var knownTokenPrefixes = []string{"github_pat_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_"} + func maskToken(token string) string { - if idx := strings.LastIndexByte(token, '_'); idx > -1 { - prefix := token[0 : idx+1] - return prefix + strings.Repeat("*", len(token)-len(prefix)) + for _, prefix := range knownTokenPrefixes { + if strings.HasPrefix(token, prefix) { + return prefix + strings.Repeat("*", len(token)-len(prefix)) + } } return strings.Repeat("*", len(token)) } diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index cb2abb90ecf..87ee5f5e5a3 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -312,7 +312,7 @@ func Test_statusRun(t *testing.T) { name: "PAT V2 token", opts: StatusOptions{}, cfgStubs: func(t *testing.T, c gh.Config) { - login(t, c, "github.com", "monalisa", "github_pat_abc123", "https") + login(t, c, "github.com", "monalisa", "github_pat_abc_123456", "https") }, httpStubs: func(reg *httpmock.Registry) { // mocks for HeaderHasMinimumScopes api requests to github.com @@ -325,7 +325,7 @@ func Test_statusRun(t *testing.T) { ✓ Logged in to github.com account monalisa (GH_CONFIG_DIR/hosts.yml) - Active account: true - Git operations protocol: https - - Token: github_pat_****** + - Token: github_pat_********** `), }, { @@ -782,3 +782,72 @@ func replaceAll(s string, old string, new string) string { replaced = strings.ReplaceAll(replaced, old, new) return replaced } + +func TestMaskToken(t *testing.T) { + tests := []struct { + name string + token string + want string + }{ + { + name: "empty token", + token: "", + want: "", + }, + { + name: "classic personal access token", + token: "ghp_abc123", + want: "ghp_******", + }, + { + name: "oauth token", + token: "gho_abc123", + want: "gho_******", + }, + { + name: "user-to-server token", + token: "ghu_abc123", + want: "ghu_******", + }, + { + name: "server-to-server token", + token: "ghs_abc123", + want: "ghs_******", + }, + { + name: "refresh token", + token: "ghr_abc123", + want: "ghr_******", + }, + { + name: "fine-grained personal access token with internal underscore", + token: "github_pat_abc_123456", + want: "github_pat_**********", + }, + { + name: "token with multiple internal underscores masks everything after prefix", + token: "ghs_aaa_bbb_ccc", + want: "ghs_***********", + }, + { + name: "unknown prefix is fully masked", + token: "unknown_abc123", + want: "**************", + }, + { + name: "token without underscore is fully masked", + token: "abc123", + want: "******", + }, + { + name: "token equal to known prefix has nothing to mask", + token: "gho_", + want: "gho_", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, maskToken(tt.token)) + }) + } +} diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go index 9125b5741ca..6bf28f76419 100644 --- a/pkg/cmd/cache/delete/delete.go +++ b/pkg/cmd/cache/delete/delete.go @@ -4,12 +4,12 @@ import ( "errors" "fmt" "net/http" - "net/url" "strconv" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/cache/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -203,8 +203,11 @@ func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface func deleteCacheByID(client *api.Client, repo ghrepo.Interface, id int64) error { // returns HTTP 204 (NO CONTENT) on success - path := fmt.Sprintf("repos/%s/actions/caches/%d", ghrepo.FullName(repo), id) - return client.REST(repo.RepoHost(), "DELETE", path, nil, nil) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "caches", strconv.FormatInt(id, 10)) + if err != nil { + return err + } + return client.REST(repo.RepoHost(), "DELETE", path.String(), nil, nil) } // deleteCacheByKey deletes cache entries by given key (and optional ref) and @@ -214,12 +217,16 @@ func deleteCacheByID(client *api.Client, repo ghrepo.Interface, id int64) error // entry. There may be more than one entries with the same key/ref combination, // but those entries will have different IDs. func deleteCacheByKey(client *api.Client, repo ghrepo.Interface, key, ref string) (int, error) { - path := fmt.Sprintf("repos/%s/actions/caches?key=%s", ghrepo.FullName(repo), url.QueryEscape(key)) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "caches") + if err != nil { + return 0, err + } + u.SetQuery("key", key) if ref != "" { - path += fmt.Sprintf("&ref=%s", url.QueryEscape(ref)) + u.SetQuery("ref", ref) } var payload shared.CachePayload - err := client.REST(repo.RepoHost(), "DELETE", path, nil, &payload) + err = client.REST(repo.RepoHost(), "DELETE", u.String(), nil, &payload) if err != nil { return 0, err } diff --git a/pkg/cmd/cache/shared/shared.go b/pkg/cmd/cache/shared/shared.go index a853b143f99..5d7a4996f13 100644 --- a/pkg/cmd/cache/shared/shared.go +++ b/pkg/cmd/cache/shared/shared.go @@ -1,12 +1,12 @@ package shared import ( - "fmt" - "net/url" + "strconv" "time" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" ) @@ -46,36 +46,40 @@ type GetCachesOptions struct { // Return a list of caches for a repository. Pass a negative limit to request // all pages from the API until all caches have been fetched. func GetCaches(client *api.Client, repo ghrepo.Interface, opts GetCachesOptions) (*CachePayload, error) { - path := fmt.Sprintf("repos/%s/actions/caches", ghrepo.FullName(repo)) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "caches") + if err != nil { + return nil, err + } perPage := 100 if opts.Limit > 0 && opts.Limit < 100 { perPage = opts.Limit } - path += fmt.Sprintf("?per_page=%d", perPage) + u.SetQuery("per_page", strconv.Itoa(perPage)) if opts.Sort != "" { - path += fmt.Sprintf("&sort=%s", opts.Sort) + u.SetQuery("sort", opts.Sort) } if opts.Order != "" { - path += fmt.Sprintf("&direction=%s", opts.Order) + u.SetQuery("direction", opts.Order) } if opts.Key != "" { - path += fmt.Sprintf("&key=%s", url.QueryEscape(opts.Key)) + u.SetQuery("key", opts.Key) } if opts.Ref != "" { - path += fmt.Sprintf("&ref=%s", url.QueryEscape(opts.Ref)) + u.SetQuery("ref", opts.Ref) } + var pageURL safeurl.SafeURL = u var result *CachePayload pagination: - for path != "" { + for pageURL.String() != "" { var response CachePayload - var err error - path, err = client.RESTWithNext(repo.RepoHost(), "GET", path, nil, &response) + next, err := client.RESTWithNext(repo.RepoHost(), "GET", pageURL.String(), nil, &response) if err != nil { return nil, err } + pageURL = safeurl.NewImmutableSafeURL(next) if result == nil { result = &response diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index 45815939b1c..2f1e0594700 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -18,6 +18,7 @@ import ( clicontext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" "golang.org/x/term" @@ -251,6 +252,12 @@ func addDeprecatedRepoShorthand(cmd *cobra.Command, target *string) error { return nil } +// validateNWO returns an error if nwo is not a valid "owner/repo" repository reference. +func validateNWO(nwo string) error { + _, _, err := safeurl.RepoPartsFromNWO(nwo) + return err +} + // filterCodespacesByRepoOwner filters a list of codespaces by the owner of the repository. func filterCodespacesByRepoOwner(codespaces []*api.Codespace, repoOwner string) []*api.Codespace { filtered := make([]*api.Codespace, 0, len(codespaces)) diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index 734509a3020..fce1901fe2d 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -88,6 +88,11 @@ func newCreateCmd(app *App) *cobra.Command { Short: "Create a codespace", Args: noArgsConstraint, PreRunE: func(cmd *cobra.Command, args []string) error { + if opts.repo != "" { + if err := validateNWO(opts.repo); err != nil { + return cmdutil.FlagErrorf("invalid value for --repo: %v", err) + } + } return cmdutil.MutuallyExclusive( "using --web with --display-name, --idle-timeout, or --retention-period is not supported", opts.useWeb, diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go index 9a959f5dae2..8579069db8c 100644 --- a/pkg/cmd/codespace/create_test.go +++ b/pkg/cmd/codespace/create_test.go @@ -32,6 +32,11 @@ func TestCreateCmdFlagError(t *testing.T) { args: "--web --idle-timeout 30m", wantsErr: fmt.Errorf("using --web with --display-name, --idle-timeout, or --retention-period is not supported"), }, + { + name: "return error when --repo is not in owner/repo format", + args: "--repo foo", + wantsErr: fmt.Errorf(`invalid value for --repo: expected the "OWNER/REPO" format, got "foo"`), + }, } for _, tt := range tests { diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go index 238c4a6a74b..ba003fd115f 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -35,6 +35,12 @@ func newListCmd(app *App) *cobra.Command { Aliases: []string{"ls"}, Args: noArgsConstraint, PreRunE: func(cmd *cobra.Command, args []string) error { + if opts.repo != "" { + if err := validateNWO(opts.repo); err != nil { + return cmdutil.FlagErrorf("invalid value for --repo: %v", err) + } + } + if err := cmdutil.MutuallyExclusive( "using `--org` or `--user` with `--repo` is not allowed", opts.repo != "", diff --git a/pkg/cmd/codespace/list_test.go b/pkg/cmd/codespace/list_test.go index 49bb0b4d29f..8ceadd449c8 100644 --- a/pkg/cmd/codespace/list_test.go +++ b/pkg/cmd/codespace/list_test.go @@ -35,6 +35,11 @@ func TestListCmdFlagError(t *testing.T) { args: "--limit -1", wantsErr: fmt.Errorf("invalid limit: -1"), }, + { + name: "list codespaces, --repo not in owner/repo format", + args: "--repo foo", + wantsErr: fmt.Errorf(`invalid value for --repo: expected the "OWNER/REPO" format, got "foo"`), + }, } for _, tt := range tests { diff --git a/pkg/cmd/codespace/logs.go b/pkg/cmd/codespace/logs.go index 37b30121760..4f8a420c7dd 100644 --- a/pkg/cmd/codespace/logs.go +++ b/pkg/cmd/codespace/logs.go @@ -88,6 +88,16 @@ func (a *App) Logs(ctx context.Context, selector *CodespaceSelector, follow bool return fmt.Errorf("remote command: %w", err) } + // The log file is external content. On a terminal, route it through + // ContentOut to neutralize escape sequences (assigning a writer other than an + // *os.File also forces the remote output through this process so the sanitizer + // runs). When piped, pass the bytes through unchanged: there is no live + // terminal to manipulate, and a follow stream cannot be buffered to fail closed. + if !a.io.IsStdoutTTY() { + a.io.SetContentSanitization(false) + } + cmd.Stdout = a.io.ContentOut + tunnelClosed := make(chan error, 1) go func() { opts := portforwarder.ForwardPortOpts{ diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index cc83ef48efa..ede7db92650 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -23,6 +23,7 @@ import ( "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safepaths" + "github.com/cli/cli/v2/internal/safeurl" ghzip "github.com/cli/cli/v2/internal/zip" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -250,32 +251,37 @@ func downloadCopilot(httpClient *http.Client, ios *iostreams.IOStreams, installD return "", fmt.Errorf("unsupported architecture: %s (supported: x64, arm64)", arch) } - var archiveURL string var archiveName string var isZip bool switch platform { case "win32": archiveName = fmt.Sprintf("copilot-%s-%s.zip", platform, arch) - archiveURL = fmt.Sprintf("https://github.com/github/copilot-cli/releases/latest/download/%s", archiveName) isZip = true case "linux", "darwin": archiveName = fmt.Sprintf("copilot-%s-%s.tar.gz", platform, arch) - archiveURL = fmt.Sprintf("https://github.com/github/copilot-cli/releases/latest/download/%s", archiveName) default: return "", fmt.Errorf("unsupported platform: %s (supported: linux, darwin, windows)", platform) } - checksumsURL := "https://github.com/github/copilot-cli/releases/latest/download/SHA256SUMS.txt" + archiveURL, err := safeurl.JoinPathWithHostPrefix("https://github.com/", "github", "copilot-cli", "releases", "latest", "download", archiveName) + if err != nil { + return "", err + } + + checksumsURL, err := safeurl.JoinPathWithHostPrefix("https://github.com/", "github", "copilot-cli", "releases", "latest", "download", "SHA256SUMS.txt") + if err != nil { + return "", err + } expectedChecksum, err := fetchExpectedChecksum(httpClient, checksumsURL, archiveName) if err != nil { return "", fmt.Errorf("failed to fetch checksums: %w", err) } - ios.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading Copilot CLI from %s", archiveURL)) + ios.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading Copilot CLI from %s", archiveURL.String())) defer ios.StopProgressIndicator() - resp, err := httpClient.Get(archiveURL) + resp, err := httpClient.Get(archiveURL.String()) if err != nil { return "", fmt.Errorf("failed to download: %w", err) } @@ -333,8 +339,8 @@ func downloadCopilot(httpClient *http.Client, ios *iostreams.IOStreams, installD } // fetchExpectedChecksum downloads the SHA256SUMS.txt file and returns the expected checksum for the given archive name. -func fetchExpectedChecksum(httpClient *http.Client, checksumsURL, archiveName string) (string, error) { - resp, err := httpClient.Get(checksumsURL) +func fetchExpectedChecksum(httpClient *http.Client, checksumsURL safeurl.SafeURL, archiveName string) (string, error) { + resp, err := httpClient.Get(checksumsURL.String()) if err != nil { return "", err } diff --git a/pkg/cmd/copilot/copilot_test.go b/pkg/cmd/copilot/copilot_test.go index fa173f5286c..58792ef7c81 100644 --- a/pkg/cmd/copilot/copilot_test.go +++ b/pkg/cmd/copilot/copilot_test.go @@ -16,6 +16,7 @@ import ( "testing" "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -341,7 +342,7 @@ func TestFetchExpectedChecksum(t *testing.T) { ) client := &http.Client{Transport: reg} - checksum, err := fetchExpectedChecksum(client, "https://example.com/checksums", "copilot-linux-x64.tar.gz") + checksum, err := fetchExpectedChecksum(client, safeurl.NewImmutableSafeURL("https://example.com/checksums"), "copilot-linux-x64.tar.gz") require.NoError(t, err, "unexpected error") require.Equal(t, "abc123def456", checksum, "checksum mismatch") }) @@ -355,7 +356,7 @@ func TestFetchExpectedChecksum(t *testing.T) { ) client := &http.Client{Transport: reg} - _, err := fetchExpectedChecksum(client, "https://example.com/checksums", "copilot-win32-x64.zip") + _, err := fetchExpectedChecksum(client, safeurl.NewImmutableSafeURL("https://example.com/checksums"), "copilot-win32-x64.zip") require.Error(t, err, "expected error for missing archive") require.Equal(t, "checksum not found for copilot-win32-x64.zip", err.Error(), "unexpected error") }) @@ -369,7 +370,7 @@ func TestFetchExpectedChecksum(t *testing.T) { ) client := &http.Client{Transport: reg} - checksum, err := fetchExpectedChecksum(client, "https://example.com/checksums", "copilot-darwin-x64.tar.gz") + checksum, err := fetchExpectedChecksum(client, safeurl.NewImmutableSafeURL("https://example.com/checksums"), "copilot-darwin-x64.tar.gz") require.NoError(t, err, "unexpected error") require.Equal(t, "abc123", checksum, "checksum mismatch") }) @@ -382,7 +383,7 @@ func TestFetchExpectedChecksum(t *testing.T) { ) client := &http.Client{Transport: reg} - _, err := fetchExpectedChecksum(client, "https://example.com/checksums", "copilot-linux-x64.tar.gz") + _, err := fetchExpectedChecksum(client, safeurl.NewImmutableSafeURL("https://example.com/checksums"), "copilot-linux-x64.tar.gz") require.Error(t, err, "expected error for HTTP 404") }) } diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index 90ccd64ceba..4ff8fa65cfb 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -3,7 +3,6 @@ package extension import ( "encoding/json" "errors" - "fmt" "io" "net/http" "os" @@ -11,11 +10,15 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { - url := fmt.Sprintf("%srepos/%s/%s", ghinstance.RESTPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName()) - req, err := http.NewRequest("GET", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName()) + if err != nil { + return false, err + } + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return false, err } @@ -37,10 +40,11 @@ func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { } func hasScript(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { - path := fmt.Sprintf("repos/%s/%s/contents/%s", - repo.RepoOwner(), repo.RepoName(), repo.RepoName()) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest("GET", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "contents", repo.RepoName()) + if err != nil { + return false, err + } + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return false, err } @@ -74,9 +78,9 @@ type release struct { } // downloadAsset downloads a single asset to the given file path. -func downloadAsset(httpClient *http.Client, asset releaseAsset, destPath string) (downloadErr error) { +func downloadAsset(httpClient *http.Client, assetURL safeurl.SafeURL, destPath string) (downloadErr error) { var req *http.Request - if req, downloadErr = http.NewRequest("GET", asset.APIURL, nil); downloadErr != nil { + if req, downloadErr = http.NewRequest("GET", assetURL.String(), nil); downloadErr != nil { return } @@ -113,9 +117,11 @@ var repositoryNotFoundErr = errors.New("repository not found") // fetchLatestRelease finds the latest published release for a repository. func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) { - path := fmt.Sprintf("repos/%s/%s/releases/latest", baseRepo.RepoOwner(), baseRepo.RepoName()) - url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path - req, err := http.NewRequest("GET", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "releases", "latest") + if err != nil { + return nil, err + } + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return nil, err } @@ -149,10 +155,11 @@ func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*re // fetchReleaseFromTag finds release by tag name for a repository func fetchReleaseFromTag(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*release, error) { - fullRepoName := fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), baseRepo.RepoName()) - path := fmt.Sprintf("repos/%s/releases/tags/%s", fullRepoName, tagName) - url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path - req, err := http.NewRequest("GET", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "releases", "tags", tagName) + if err != nil { + return nil, err + } + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return nil, err } @@ -186,9 +193,11 @@ func fetchReleaseFromTag(httpClient *http.Client, baseRepo ghrepo.Interface, tag // fetchCommitSHA finds full commit SHA from a target ref in a repo func fetchCommitSHA(httpClient *http.Client, baseRepo ghrepo.Interface, targetRef string) (string, error) { - path := fmt.Sprintf("repos/%s/%s/commits/%s", baseRepo.RepoOwner(), baseRepo.RepoName(), targetRef) - url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path - req, err := http.NewRequest("GET", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "commits", targetRef) + if err != nil { + return "", err + } + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return "", err } diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index de758f5a3d2..f1528743a39 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -20,6 +20,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/findsh" "github.com/cli/cli/v2/pkg/iostreams" @@ -346,7 +347,7 @@ func (m *Manager) installBin(repo ghrepo.Interface, target string) error { binPath := filepath.Join(targetDir, name) binPath += ext - err = downloadAsset(m.client, *asset, binPath) + err = downloadAsset(m.client, safeurl.NewImmutableSafeURL(asset.APIURL), binPath) if err != nil { return fmt.Errorf("failed to download asset %s: %w", asset.Name, err) } diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 06b2336b663..6ed4f66263d 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -18,6 +18,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/gist/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -272,8 +273,11 @@ func createGist(client *http.Client, hostname, description string, public bool, return nil, err } - u := ghinstance.RESTPrefix(hostname) + "gists" - req, err := http.NewRequest(http.MethodPost, u, requestBody) + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(hostname), "gists") + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, u.String(), requestBody) if err != nil { return nil, err } diff --git a/pkg/cmd/gist/delete/delete.go b/pkg/cmd/gist/delete/delete.go index 319f9265e99..4413bc8bffb 100644 --- a/pkg/cmd/gist/delete/delete.go +++ b/pkg/cmd/gist/delete/delete.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/gist/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -142,8 +143,11 @@ func deleteRun(opts *DeleteOptions) error { } func deleteGist(apiClient *api.Client, hostname string, gistID string) error { - path := "gists/" + gistID - err := apiClient.REST(hostname, "DELETE", path, nil, nil) + path, err := safeurl.JoinPath("gists", gistID) + if err != nil { + return err + } + err = apiClient.REST(hostname, "DELETE", path.String(), nil, nil) if err != nil { var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go index 6f00c906cbd..8195c6aa0f9 100644 --- a/pkg/cmd/gist/edit/edit.go +++ b/pkg/cmd/gist/edit/edit.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/gist/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -287,12 +288,14 @@ func editRun(opts *EditOptions) error { file := gist.Files[filename] if file.Truncated { if _, alreadyEdited := filesToUpdate[filename]; !alreadyEdited { - fullContent, err := shared.GetRawGistFile(client, file.RawURL) + fullContent, err := shared.GetRawGistFile(client, safeurl.NewImmutableSafeURL(file.RawURL)) if err != nil { return err } - gistFile.Content = fullContent + // Round-trip path: the content is opened in an editor and sent + // back to the API, so the raw bytes must be preserved verbatim. + gistFile.Content = fullContent.Raw() } } @@ -402,8 +405,11 @@ func updateGist(apiClient *api.Client, hostname string, gist gistToUpdate) error requestBody := bytes.NewReader(requestByte) result := shared.Gist{} - path := "gists/" + gist.id - err = apiClient.REST(hostname, "POST", path, requestBody, &result) + path, err := safeurl.JoinPath("gists", gist.id) + if err != nil { + return err + } + err = apiClient.REST(hostname, "POST", path.String(), requestBody, &result) if err != nil { return err } diff --git a/pkg/cmd/gist/rename/rename.go b/pkg/cmd/gist/rename/rename.go index 96f630c025d..d5ef2d9199c 100644 --- a/pkg/cmd/gist/rename/rename.go +++ b/pkg/cmd/gist/rename/rename.go @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/gist/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -119,7 +120,10 @@ func updateGist(apiClient *api.Client, hostname string, gist *shared.Gist) error Files: gist.Files, } - path := "gists/" + gist.ID + path, err := safeurl.JoinPath("gists", gist.ID) + if err != nil { + return err + } requestByte, err := json.Marshal(body) if err != nil { @@ -130,7 +134,7 @@ func updateGist(apiClient *api.Client, hostname string, gist *shared.Gist) error result := shared.Gist{} - err = apiClient.REST(hostname, "POST", path, requestBody, &result) + err = apiClient.REST(hostname, "POST", path.String(), requestBody, &result) if err != nil { return err diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index 305e2f6426b..7c0a7c07565 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -13,6 +13,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/iostreams" "github.com/gabriel-vasile/mimetype" @@ -62,10 +63,13 @@ var NotFoundErr = errors.New("not found") func GetGist(client *http.Client, hostname, gistID string) (*Gist, error) { gist := Gist{} - path := fmt.Sprintf("gists/%s", gistID) + path, err := safeurl.JoinPath("gists", gistID) + if err != nil { + return nil, err + } apiClient := api.NewClientFromHTTP(client) - err := apiClient.REST(hostname, "GET", path, nil, &gist) + err = apiClient.REST(hostname, "GET", path.String(), nil, &gist) if err != nil { var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { @@ -248,28 +252,31 @@ func PromptGists(prompter prompter.Prompter, client *http.Client, host string, c return &gists[result], nil } -func GetRawGistFile(httpClient *http.Client, rawURL string) (string, error) { - req, err := http.NewRequest("GET", rawURL, nil) +// GetRawGistFile fetches the full content of a gist file from its raw URL. The +// bytes are external content, so they are returned as iostreams.Untrusted to +// force callers to choose between sanitized display and raw round-tripping. +func GetRawGistFile(httpClient *http.Client, rawURL safeurl.SafeURL) (iostreams.Untrusted, error) { + req, err := http.NewRequest("GET", rawURL.String(), nil) if err != nil { - return "", err + return iostreams.Untrusted{}, err } resp, err := httpClient.Do(req) if err != nil { - return "", err + return iostreams.Untrusted{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", api.HandleHTTPError(resp) + return iostreams.Untrusted{}, api.HandleHTTPError(resp) } body, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return iostreams.Untrusted{}, err } - return string(body), nil + return iostreams.NewUntrustedBytes(body), nil } diff --git a/pkg/cmd/gist/shared/shared_test.go b/pkg/cmd/gist/shared/shared_test.go index d75ebc2b72d..8b891d8b9fe 100644 --- a/pkg/cmd/gist/shared/shared_test.go +++ b/pkg/cmd/gist/shared/shared_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -298,7 +299,7 @@ func TestGetRawGistFile(t *testing.T) { ) client := &http.Client{Transport: reg} - result, err := GetRawGistFile(client, "https://gist.githubusercontent.com/raw-url") + result, err := GetRawGistFile(client, safeurl.NewImmutableSafeURL("https://gist.githubusercontent.com/raw-url")) if tt.wantErr { assert.Error(t, err) @@ -307,7 +308,7 @@ func TestGetRawGistFile(t *testing.T) { } } else { assert.NoError(t, err) - assert.Equal(t, tt.want, result) + assert.Equal(t, tt.want, result.Raw()) } reg.Verify(t) diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go index 9eb906cde8f..51ec38d0443 100644 --- a/pkg/cmd/gist/view/view.go +++ b/pkg/cmd/gist/view/view.go @@ -1,6 +1,7 @@ package view import ( + "errors" "fmt" "net/http" "sort" @@ -9,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/gist/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -33,6 +35,8 @@ type ViewOptions struct { Raw bool Web bool ListFiles bool + + AllowEscapeSequences bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -69,12 +73,18 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open gist in the browser") cmd.Flags().BoolVar(&opts.ListFiles, "files", false, "List file names from the gist") cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "Display a single file from the gist") + cmd.Flags().BoolVar(&opts.AllowEscapeSequences, "allow-escape-sequences", false, "Allow printing terminal escape sequences") return cmd } func viewRun(opts *ViewOptions) error { gistID := opts.Selector + + if opts.AllowEscapeSequences { + opts.IO.SetContentSanitization(false) + } + client, err := opts.HttpClient() if err != nil { return err @@ -136,17 +146,19 @@ func viewRun(opts *ViewOptions) error { defer opts.IO.StopPager() render := func(gf *shared.GistFile) error { + // Treat the file content as untrusted external bytes. The truncated + // path fetches the full content from the raw URL. + content := iostreams.NewUntrusted(gf.Content) if gf.Truncated { - fullContent, err := shared.GetRawGistFile(client, gf.RawURL) - + fullContent, err := shared.GetRawGistFile(client, safeurl.NewImmutableSafeURL(gf.RawURL)) if err != nil { return err } - gf.Content = fullContent + content = fullContent } - if shared.IsBinaryContents([]byte(gf.Content)) { + if shared.IsBinaryContents(content.RawBytes()) { if len(gist.Files) == 1 || opts.Filename != "" { return fmt.Errorf("error: file is binary") } @@ -155,7 +167,10 @@ func viewRun(opts *ViewOptions) error { } if strings.Contains(gf.Type, "markdown") && !opts.Raw { - rendered, err := markdown.Render(gf.Content, + // Markdown rendering emits application-styled output to Out, so its + // input is sanitized here; --allow-escape-sequences applies to the + // raw dump below. + rendered, err := markdown.Render(content.String(), markdown.WithTheme(opts.IO.TerminalTheme()), markdown.WithWrap(opts.IO.TerminalWidth())) if err != nil { @@ -165,11 +180,22 @@ func viewRun(opts *ViewOptions) error { return err } - if _, err := fmt.Fprint(opts.IO.Out, gf.Content); err != nil { + // Raw dump. On a terminal, ContentOut renders escape sequences inert. + // When the output is piped, refuse content carrying escape sequences + // rather than silently rewriting the bytes; --allow-escape-sequences + // forces raw. + if !opts.AllowEscapeSequences && !opts.IO.IsStdoutTTY() { + if iostreams.ContainsEscapeSequence(content.RawBytes()) { + return errors.New("gist file contains terminal escape sequences; pass --allow-escape-sequences to view it anyway") + } + opts.IO.SetContentSanitization(false) + } + raw := content.Raw() + if _, err := fmt.Fprint(opts.IO.ContentOut, raw); err != nil { return err } - if !strings.HasSuffix(gf.Content, "\n") { - _, err := fmt.Fprint(opts.IO.Out, "\n") + if !strings.HasSuffix(raw, "\n") { + _, err := fmt.Fprint(opts.IO.ContentOut, "\n") return err } diff --git a/pkg/cmd/gist/view/view_test.go b/pkg/cmd/gist/view/view_test.go index 85c2b7ad86b..dcfe561ef66 100644 --- a/pkg/cmd/gist/view/view_test.go +++ b/pkg/cmd/gist/view/view_test.go @@ -147,6 +147,100 @@ func Test_viewRun(t *testing.T) { }, wantOut: "bwhiizzzbwhuiiizzzz\n", }, + { + name: "truncated raw file with escape sequences is sanitized on a terminal", + isTTY: true, + opts: &ViewOptions{ + Selector: "1234", + ListFiles: false, + }, + mockGist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "escaped.txt": { + Type: "text/plain", + Content: "", + Truncated: true, + RawURL: "https://gist.githubusercontent.com/user/1234/raw/escaped.txt", + }, + }, + }, + wantOut: "danger^[[31m\n", + }, + { + name: "piped truncated raw file with escape sequences is refused", + isTTY: false, + opts: &ViewOptions{ + Selector: "1234", + ListFiles: false, + }, + mockGist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "escaped.txt": { + Type: "text/plain", + Content: "", + Truncated: true, + RawURL: "https://gist.githubusercontent.com/user/1234/raw/escaped.txt", + }, + }, + }, + wantErr: "gist file contains terminal escape sequences; pass --allow-escape-sequences to view it anyway", + }, + { + name: "piped truncated clean file passes through raw", + isTTY: false, + opts: &ViewOptions{ + Selector: "1234", + ListFiles: false, + }, + mockGist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "clean-truncated.txt": { + Type: "text/plain", + Content: "", + Truncated: true, + RawURL: "https://gist.githubusercontent.com/user/1234/raw/clean-truncated.txt", + }, + }, + }, + wantOut: "clean text\n", + }, + { + name: "piped truncated file with escape sequences passes through with --allow-escape-sequences", + isTTY: false, + opts: &ViewOptions{ + Selector: "1234", + ListFiles: false, + AllowEscapeSequences: true, + }, + mockGist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "escaped.txt": { + Type: "text/plain", + Content: "", + Truncated: true, + RawURL: "https://gist.githubusercontent.com/user/1234/raw/escaped.txt", + }, + }, + }, + wantOut: "danger\x1b[31m\n", + }, + { + name: "piped inline file escapes are already neutralized by the JSON transport", + isTTY: false, + opts: &ViewOptions{ + Selector: "1234", + ListFiles: false, + }, + mockGist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "inline-escaped.txt": { + Type: "text/plain", + Content: "danger\x1b[31m", + }, + }, + }, + wantOut: "danger^[[31m\n", + }, { name: "one file, no ID supplied", isTTY: true, @@ -453,6 +547,12 @@ func Test_viewRun(t *testing.T) { } else if filename == "also-truncated.txt" { reg.Register(httpmock.REST("GET", "user/1234/raw/also-truncated.txt"), httpmock.StringResponse("This is the full content of the also-truncated file retrieved from raw URL")) + } else if filename == "escaped.txt" { + reg.Register(httpmock.REST("GET", "user/1234/raw/escaped.txt"), + httpmock.StringResponse("danger\x1b[31m")) + } else if filename == "clean-truncated.txt" { + reg.Register(httpmock.REST("GET", "user/1234/raw/clean-truncated.txt"), + httpmock.StringResponse("clean text")) } } } diff --git a/pkg/cmd/gpg-key/add/http.go b/pkg/cmd/gpg-key/add/http.go index 4b2a6e97c4f..b1f0fca74cd 100644 --- a/pkg/cmd/gpg-key/add/http.go +++ b/pkg/cmd/gpg-key/add/http.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" ) var errScopesMissing = errors.New("insufficient OAuth scopes") @@ -16,7 +17,10 @@ var errDuplicateKey = errors.New("key already exists") var errWrongFormat = errors.New("key in wrong format") func gpgKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) error { - url := ghinstance.RESTPrefix(hostname) + "user/gpg_keys" + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(hostname), "user", "gpg_keys") + if err != nil { + return err + } keyBytes, err := io.ReadAll(keyFile) if err != nil { @@ -35,7 +39,7 @@ func gpgKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t return err } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) + req, err := http.NewRequest("POST", u.String(), bytes.NewBuffer(payloadBytes)) if err != nil { return err } diff --git a/pkg/cmd/gpg-key/delete/http.go b/pkg/cmd/gpg-key/delete/http.go index 22b43133b86..9b6c2a46eae 100644 --- a/pkg/cmd/gpg-key/delete/http.go +++ b/pkg/cmd/gpg-key/delete/http.go @@ -2,12 +2,12 @@ package delete import ( "encoding/json" - "fmt" "io" "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" ) type gpgKey struct { @@ -16,8 +16,11 @@ type gpgKey struct { } func deleteGPGKey(httpClient *http.Client, host, id string) error { - url := fmt.Sprintf("%suser/gpg_keys/%s", ghinstance.RESTPrefix(host), id) - req, err := http.NewRequest("DELETE", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "user", "gpg_keys", id) + if err != nil { + return err + } + req, err := http.NewRequest("DELETE", url.String(), nil) if err != nil { return err } @@ -36,9 +39,12 @@ func deleteGPGKey(httpClient *http.Client, host, id string) error { } func getGPGKeys(httpClient *http.Client, host string) ([]gpgKey, error) { - resource := "user/gpg_keys" - url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) - req, err := http.NewRequest("GET", url, nil) + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "user", "gpg_keys") + if err != nil { + return nil, err + } + u.SetQuery("per_page", "100") + req, err := http.NewRequest("GET", u.String(), nil) if err != nil { return nil, err } diff --git a/pkg/cmd/gpg-key/list/http.go b/pkg/cmd/gpg-key/list/http.go index 804119f355e..1b00684590e 100644 --- a/pkg/cmd/gpg-key/list/http.go +++ b/pkg/cmd/gpg-key/list/http.go @@ -3,7 +3,6 @@ package list import ( "encoding/json" "errors" - "fmt" "io" "net/http" "strings" @@ -11,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" ) var errScopes = errors.New("insufficient OAuth scopes") @@ -38,12 +38,18 @@ type gpgKey struct { } func userKeys(httpClient *http.Client, host, userHandle string) ([]gpgKey, error) { - resource := "user/gpg_keys" + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "user", "gpg_keys") + if err != nil { + return nil, err + } if userHandle != "" { - resource = fmt.Sprintf("users/%s/gpg_keys", userHandle) + u, err = safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "users", userHandle, "gpg_keys") + if err != nil { + return nil, err + } } - url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) - req, err := http.NewRequest("GET", url, nil) + u.SetQuery("per_page", "100") + req, err := http.NewRequest("GET", u.String(), nil) if err != nil { return nil, err } diff --git a/pkg/cmd/label/create.go b/pkg/cmd/label/create.go index 9d6b2e9ee86..58954372989 100644 --- a/pkg/cmd/label/create.go +++ b/pkg/cmd/label/create.go @@ -13,6 +13,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -127,7 +128,10 @@ func createRun(opts *createOptions) error { func createLabel(client *http.Client, repo ghrepo.Interface, opts *createOptions) error { apiClient := api.NewClientFromHTTP(client) - path := fmt.Sprintf("repos/%s/%s/labels", repo.RepoOwner(), repo.RepoName()) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "labels") + if err != nil { + return err + } requestByte, err := json.Marshal(map[string]string{ "name": opts.Name, "description": opts.Description, @@ -137,7 +141,7 @@ func createLabel(client *http.Client, repo ghrepo.Interface, opts *createOptions return err } requestBody := bytes.NewReader(requestByte) - err = apiClient.REST(repo.RepoHost(), "POST", path, requestBody, nil) + err = apiClient.REST(repo.RepoHost(), "POST", path.String(), requestBody, nil) if httpError, ok := err.(api.HTTPError); ok && isLabelAlreadyExistsError(httpError) { err = errLabelAlreadyExists @@ -156,7 +160,10 @@ func createLabel(client *http.Client, repo ghrepo.Interface, opts *createOptions } func updateLabel(apiClient *api.Client, repo ghrepo.Interface, opts *editOptions) error { - path := fmt.Sprintf("repos/%s/%s/labels/%s", repo.RepoOwner(), repo.RepoName(), opts.Name) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "labels", opts.Name) + if err != nil { + return err + } properties := map[string]string{} if opts.Description != "" { properties["description"] = opts.Description @@ -172,7 +179,7 @@ func updateLabel(apiClient *api.Client, repo ghrepo.Interface, opts *editOptions return err } requestBody := bytes.NewReader(requestByte) - err = apiClient.REST(repo.RepoHost(), "PATCH", path, requestBody, nil) + err = apiClient.REST(repo.RepoHost(), "PATCH", path.String(), requestBody, nil) if httpError, ok := err.(api.HTTPError); ok && isLabelAlreadyExistsError(httpError) { err = errLabelAlreadyExists diff --git a/pkg/cmd/label/delete.go b/pkg/cmd/label/delete.go index c9d8f4caea8..8dd1532c127 100644 --- a/pkg/cmd/label/delete.go +++ b/pkg/cmd/label/delete.go @@ -6,6 +6,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -94,7 +95,10 @@ func deleteRun(opts *deleteOptions) error { func deleteLabel(client *http.Client, repo ghrepo.Interface, name string) error { apiClient := api.NewClientFromHTTP(client) - path := fmt.Sprintf("repos/%s/%s/labels/%s", repo.RepoOwner(), repo.RepoName(), name) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "labels", name) + if err != nil { + return err + } - return apiClient.REST(repo.RepoHost(), "DELETE", path, nil, nil) + return apiClient.REST(repo.RepoHost(), "DELETE", path.String(), nil, nil) } diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index 57ee0f0e643..17214915779 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -157,7 +157,7 @@ func TestPrClose_deleteBranch_sameRepo(t *testing.T) { }), ) http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fblueberries"), httpmock.StringResponse(`{}`)) cs, cmdTeardown := run.Stub() @@ -223,7 +223,7 @@ func TestPrClose_deleteBranch_sameBranch(t *testing.T) { }), ) http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/trunk"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Ftrunk"), httpmock.StringResponse(`{}`)) cs, cmdTeardown := run.Stub() @@ -258,7 +258,7 @@ func TestPrClose_deleteBranch_notInGitRepo(t *testing.T) { }), ) http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/trunk"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Ftrunk"), httpmock.StringResponse(`{}`)) cs, cmdTeardown := run.Stub() diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index 91dc14ef472..3e627b8b806 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -9,19 +9,20 @@ import ( "net/http" "path" "regexp" + "strconv" "strings" - "unicode" - "unicode/utf8" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/asciisanitizer" "github.com/spf13/cobra" "golang.org/x/text/transform" ) @@ -39,6 +40,8 @@ type DiffOptions struct { NameOnly bool BrowserMode bool Exclude []string + + AllowEscapeSequences bool } func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command { @@ -64,6 +67,10 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman Use %[1]s--exclude%[1]s to filter out files matching a glob pattern. The pattern uses forward slashes as path separators on all platforms. You can repeat the flag to exclude multiple patterns. + + By default, terminal escape sequences in the diff are neutralized, since + they could manipulate your terminal. Pass %[1]s--allow-escape-sequences%[1]s to + print the diff verbatim, for example when piping a patch to another program. `, "`"), Example: heredoc.Doc(` # See diff for current branch @@ -113,6 +120,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman cmd.Flags().BoolVar(&opts.NameOnly, "name-only", false, "Display only names of changed files") cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open the pull request diff in the browser") cmd.Flags().StringSliceVarP(&opts.Exclude, "exclude", "e", nil, "Exclude files matching glob `patterns` from the diff") + cmd.Flags().BoolVar(&opts.AllowEscapeSequences, "allow-escape-sequences", false, "Allow printing terminal escape sequences") return cmd } @@ -163,8 +171,12 @@ func diffRun(opts *DiffOptions) error { } diff = filtered } - if opts.IO.IsStdoutTTY() { - diff = sanitizedReader(diff) + // A terminal shows escape sequences inert through ContentOut; piped output is + // faithful, so a diff carrying escape sequences is refused rather than silently + // altered. --allow-escape-sequences streams raw on both. The colored path + // always neutralizes, since it is terminal-bound. + if opts.AllowEscapeSequences { + opts.IO.SetContentSanitization(false) } if err := opts.IO.StartPager(); err == nil { @@ -174,30 +186,41 @@ func diffRun(opts *DiffOptions) error { } if opts.NameOnly { - return changedFilesNames(opts.IO.Out, diff) + return changedFilesNames(opts.IO.ContentOut, diff) + } + + if opts.UseColor { + return colorDiffLines(opts.IO.Out, sanitizedReader(diff)) } - if !opts.UseColor { - _, err = io.Copy(opts.IO.Out, diff) + if !opts.AllowEscapeSequences && !opts.IO.IsStdoutTTY() { + data, err := io.ReadAll(diff) + if err != nil { + return err + } + if iostreams.ContainsEscapeSequence(data) { + return errors.New("the diff contains terminal escape sequences; pass --allow-escape-sequences to output it anyway") + } + opts.IO.SetContentSanitization(false) + _, err = opts.IO.ContentOut.Write(data) return err } - return colorDiffLines(opts.IO.Out, diff) + _, err = io.Copy(opts.IO.ContentOut, diff) + return err } func fetchDiff(httpClient *http.Client, baseRepo ghrepo.Interface, prNumber int, asPatch bool) (io.ReadCloser, error) { - url := fmt.Sprintf( - "%srepos/%s/pulls/%d", - ghinstance.RESTPrefix(baseRepo.RepoHost()), - ghrepo.FullName(baseRepo), - prNumber, - ) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "pulls", strconv.Itoa(prNumber)) + if err != nil { + return nil, err + } acceptType := "application/vnd.github.v3.diff" if asPatch { acceptType = "application/vnd.github.v3.patch" } - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return nil, err } @@ -333,56 +356,7 @@ func changedFilesNames(w io.Writer, r io.Reader) error { } func sanitizedReader(r io.Reader) io.Reader { - return transform.NewReader(r, sanitizer{}) -} - -// sanitizer replaces non-printable characters with their printable representations -type sanitizer struct{ transform.NopResetter } - -// Transform implements transform.Transformer. -func (t sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { - for r, size := rune(0), 0; nSrc < len(src); { - if r = rune(src[nSrc]); r < utf8.RuneSelf { - size = 1 - } else if r, size = utf8.DecodeRune(src[nSrc:]); size == 1 && !atEOF && !utf8.FullRune(src[nSrc:]) { - // Invalid rune. - err = transform.ErrShortSrc - break - } - - if isPrint(r) { - if nDst+size > len(dst) { - err = transform.ErrShortDst - break - } - for i := 0; i < size; i++ { - dst[nDst] = src[nSrc] - nDst++ - nSrc++ - } - continue - } else { - nSrc += size - } - - replacement := fmt.Sprintf("\\u{%02x}", r) - - if nDst+len(replacement) > len(dst) { - err = transform.ErrShortDst - break - } - - for _, c := range replacement { - dst[nDst] = byte(c) - nDst++ - } - } - return -} - -// isPrint reports if a rune is safe to be printed to a terminal -func isPrint(r rune) bool { - return r == '\n' || r == '\r' || r == '\t' || unicode.IsPrint(r) + return transform.NewReader(r, &asciisanitizer.Sanitizer{}) } var diffHeaderRegexp = regexp.MustCompile(`(?:^|\n)diff\s--git.*\s("?)b/(.*)`) diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index 6a91ba9ca96..b95ac8a8311 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -123,6 +123,16 @@ func Test_NewCmdDiff(t *testing.T) { BrowserMode: true, }, }, + { + name: "allow escape sequences", + args: "--allow-escape-sequences", + isTTY: true, + want: DiffOptions{ + SelectorArg: "", + UseColor: true, + AllowEscapeSequences: true, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -163,6 +173,7 @@ func Test_NewCmdDiff(t *testing.T) { assert.Equal(t, tt.want.UseColor, opts.UseColor) assert.Equal(t, tt.want.BrowserMode, opts.BrowserMode) assert.Equal(t, tt.want.Exclude, opts.Exclude) + assert.Equal(t, tt.want.AllowEscapeSequences, opts.AllowEscapeSequences) }) } } @@ -173,9 +184,11 @@ func Test_diffRun(t *testing.T) { tests := []struct { name string opts DiffOptions + notTTY bool wantFields []string wantStdout string wantStderr string + wantErr string wantBrowsedURL string httpStubs func(*httpmock.Registry) }{ @@ -284,6 +297,57 @@ index f2b4805c..3d7bd0f9 100644 wantStderr: "Opening https://github.com/OWNER/REPO/pull/123/files in your browser.\n", wantBrowsedURL: "https://github.com/OWNER/REPO/pull/123/files", }, + { + name: "neutralizes escape sequences by default", + opts: DiffOptions{ + SelectorArg: "123", + UseColor: false, + }, + wantFields: []string{"number"}, + wantStdout: "diff --git a/f b/f\n+ hello ^[[m world\n", + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.diff", "diff --git a/f b/f\n+ hello \x1b[m world\n") + }, + }, + { + name: "passes escape sequences through with --allow-escape-sequences", + opts: DiffOptions{ + SelectorArg: "123", + UseColor: false, + AllowEscapeSequences: true, + }, + wantFields: []string{"number"}, + wantStdout: "diff --git a/f b/f\n+ hello \x1b[m world\n", + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.diff", "diff --git a/f b/f\n+ hello \x1b[m world\n") + }, + }, + { + name: "piped diff with escape sequences is refused", + opts: DiffOptions{ + SelectorArg: "123", + UseColor: false, + }, + notTTY: true, + wantFields: []string{"number"}, + wantErr: "the diff contains terminal escape sequences; pass --allow-escape-sequences to output it anyway", + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.diff", "diff --git a/f b/f\n+ hello \x1b[m world\n") + }, + }, + { + name: "piped clean diff passes through raw", + opts: DiffOptions{ + SelectorArg: "123", + UseColor: false, + }, + notTTY: true, + wantFields: []string{"number"}, + wantStdout: "diff --git a/f b/f\n+ hello world\n", + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.diff", "diff --git a/f b/f\n+ hello world\n") + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -300,7 +364,7 @@ index f2b4805c..3d7bd0f9 100644 tt.opts.Browser = browser ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(true) + ios.SetStdoutTTY(!tt.notTTY) tt.opts.IO = ios finder := shared.NewMockFinder("123", pr, ghrepo.New("OWNER", "REPO")) @@ -308,7 +372,11 @@ index f2b4805c..3d7bd0f9 100644 tt.opts.Finder = finder err := diffRun(&tt.opts) - assert.NoError(t, err) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } assert.Equal(t, tt.wantStdout, stdout.String()) assert.Equal(t, tt.wantStderr, stderr.String()) @@ -569,7 +637,7 @@ func Test_matchesAny(t *testing.T) { func Test_sanitizedReader(t *testing.T) { input := strings.NewReader("\t hello \x1B[m world! ăѣ𝔠ծề\r\n") - expected := "\t hello \\u{1b}[m world! ăѣ𝔠ծề\r\n" + expected := "\t hello ^[[m world! ăѣ𝔠ծề\r\n" err := iotest.TestReader(sanitizedReader(input), []byte(expected)) if err != nil { diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 03ddafa61ae..d7e02aa715a 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -635,7 +635,7 @@ func TestPrMerge_deleteBranch(t *testing.T) { assert.NotContains(t, input, "commitHeadline") })) http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fblueberries"), httpmock.StringResponse(`{}`)) cs, cmdTeardown := run.Stub() @@ -701,7 +701,7 @@ func TestPrMerge_deleteBranch_apiError(t *testing.T) { ✓ Merged pull request OWNER/REPO#10 (Blueberries are a good fruit) ✓ Deleted local branch blueberries and switched to branch main `), - wantErr: "failed to delete remote branch blueberries: HTTP 500: blah blah (https://api.github.com/repos/OWNER/REPO/git/refs/heads/blueberries)", + wantErr: "failed to delete remote branch blueberries: HTTP 500: blah blah (https://api.github.com/repos/OWNER/REPO/git/refs/heads%2Fblueberries)", }, } @@ -732,7 +732,7 @@ func TestPrMerge_deleteBranch_apiError(t *testing.T) { assert.NotContains(t, input, "commitHeadline") })) http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fblueberries"), httpmock.JSONErrorResponse(tt.apiError.StatusCode, tt.apiError)) cs, cmdTeardown := run.Stub() @@ -806,7 +806,7 @@ func TestPrMerge_deleteBranch_nonDefault(t *testing.T) { assert.NotContains(t, input, "commitHeadline") })) http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fblueberries"), httpmock.StringResponse(`{}`)) cs, cmdTeardown := run.Stub() @@ -905,7 +905,7 @@ func TestPrMerge_deleteBranch_checkoutNewBranch(t *testing.T) { assert.NotContains(t, input, "commitHeadline") })) http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fblueberries"), httpmock.StringResponse(`{}`)) cs, cmdTeardown := run.Stub() @@ -955,7 +955,7 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { assert.NotContains(t, input, "commitHeadline") })) http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fblueberries"), httpmock.StringResponse(`{}`)) cs, cmdTeardown := run.Stub() @@ -1435,7 +1435,7 @@ func TestPRMergeTTY_withDeleteBranch(t *testing.T) { assert.NotContains(t, input, "commitHeadline") })) http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fblueberries"), httpmock.StringResponse(`{}`)) cs, cmdTeardown := run.Stub() diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 8771b2477a8..5a8d995bc08 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -13,6 +13,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -534,7 +535,7 @@ func createRun(opts *CreateOptions) error { if !draftWhileUploading { return err } - if cleanupErr := deleteRelease(httpClient, newRelease); cleanupErr != nil { + if cleanupErr := deleteRelease(httpClient, safeurl.NewImmutableSafeURL(newRelease.APIURL)); cleanupErr != nil { return fmt.Errorf("%w\ncleaning up draft failed: %v", err, cleanupErr) } return err @@ -547,14 +548,14 @@ func createRun(opts *CreateOptions) error { } opts.IO.StartProgressIndicator() - err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets) + err = shared.ConcurrentUpload(httpClient, safeurl.NewImmutableSafeURL(uploadURL), opts.Concurrency, opts.Assets) opts.IO.StopProgressIndicator() if err != nil { return cleanupDraftRelease(err) } if draftWhileUploading { - rel, err := publishRelease(httpClient, newRelease.APIURL, opts.DiscussionCategory, opts.IsLatest) + rel, err := publishRelease(httpClient, safeurl.NewImmutableSafeURL(newRelease.APIURL), opts.DiscussionCategory, opts.IsLatest) if err != nil { return cleanupDraftRelease(err) } diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go index 4311a389693..abee74e8be1 100644 --- a/pkg/cmd/release/create/http.go +++ b/pkg/cmd/release/create/http.go @@ -8,13 +8,14 @@ import ( "fmt" "io" "net/http" - "net/url" "slices" + "strconv" "strings" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/shurcooL/githubv4" @@ -60,9 +61,12 @@ func remoteTagExists(httpClient *http.Client, repo ghrepo.Interface, tagName str } func getTags(httpClient *http.Client, repo ghrepo.Interface, limit int) ([]tag, error) { - path := fmt.Sprintf("repos/%s/%s/tags?per_page=%d", repo.RepoOwner(), repo.RepoName(), limit) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest("GET", url, nil) + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "tags") + if err != nil { + return nil, err + } + u.SetQuery("per_page", strconv.Itoa(limit)) + req, err := http.NewRequest("GET", u.String(), nil) if err != nil { return nil, err } @@ -106,9 +110,11 @@ func generateReleaseNotes(httpClient *http.Client, repo ghrepo.Interface, tagNam return nil, err } - path := fmt.Sprintf("repos/%s/%s/releases/generate-notes", repo.RepoOwner(), repo.RepoName()) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes)) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", "generate-notes") + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", url.String(), bytes.NewBuffer(bodyBytes)) if err != nil { return nil, err } @@ -142,9 +148,11 @@ func generateReleaseNotes(httpClient *http.Client, repo ghrepo.Interface, tagNam } func publishedReleaseExists(httpClient *http.Client, repo ghrepo.Interface, tagName string) (bool, error) { - path := fmt.Sprintf("repos/%s/%s/releases/tags/%s", repo.RepoOwner(), repo.RepoName(), url.PathEscape(tagName)) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest("HEAD", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", "tags", tagName) + if err != nil { + return false, err + } + req, err := http.NewRequest("HEAD", url.String(), nil) if err != nil { return false, err } @@ -172,9 +180,11 @@ func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[st return nil, err } - path := fmt.Sprintf("repos/%s/%s/releases", repo.RepoOwner(), repo.RepoName()) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes)) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases") + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", url.String(), bytes.NewBuffer(bodyBytes)) if err != nil { return nil, err } @@ -220,7 +230,7 @@ func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[st return &newRelease, err } -func publishRelease(httpClient *http.Client, releaseURL string, discussionCategory string, isLatest *bool) (*shared.Release, error) { +func publishRelease(httpClient *http.Client, releaseURL safeurl.SafeURL, discussionCategory string, isLatest *bool) (*shared.Release, error) { params := map[string]interface{}{"draft": false} if discussionCategory != "" { params["discussion_category_name"] = discussionCategory @@ -234,7 +244,7 @@ func publishRelease(httpClient *http.Client, releaseURL string, discussionCatego if err != nil { return nil, err } - req, err := http.NewRequest("PATCH", releaseURL, bytes.NewBuffer(bodyBytes)) + req, err := http.NewRequest("PATCH", releaseURL.String(), bytes.NewBuffer(bodyBytes)) if err != nil { return nil, err } @@ -261,8 +271,8 @@ func publishRelease(httpClient *http.Client, releaseURL string, discussionCatego return &release, err } -func deleteRelease(httpClient *http.Client, release *shared.Release) error { - req, err := http.NewRequest("DELETE", release.APIURL, nil) +func deleteRelease(httpClient *http.Client, releaseURL safeurl.SafeURL) error { + req, err := http.NewRequest("DELETE", releaseURL.String(), nil) if err != nil { return err } @@ -314,14 +324,18 @@ func isNewRelease(httpClient *http.Client, repo ghrepo.Interface) (bool, error) } tagName := release.TagName - path := fmt.Sprintf("repos/%s/%s/compare/%s...HEAD?per_page=1", repo.RepoOwner(), repo.RepoName(), tagName) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "compare", tagName+"...HEAD") + if err != nil { + return false, err + } + u.SetQuery("per_page", "1") var comparisonStatus struct { Status string `json:"status"` } apiClient := api.NewClientFromHTTP(httpClient) - if err := apiClient.REST(repo.RepoHost(), "GET", path, nil, &comparisonStatus); err != nil { + if err := apiClient.REST(repo.RepoHost(), "GET", u.String(), nil, &comparisonStatus); err != nil { return false, err } diff --git a/pkg/cmd/release/delete-asset/delete_asset.go b/pkg/cmd/release/delete-asset/delete_asset.go index b2e1f22fea1..3aedc3e3a46 100644 --- a/pkg/cmd/release/delete-asset/delete_asset.go +++ b/pkg/cmd/release/delete-asset/delete_asset.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -96,7 +97,7 @@ func deleteAssetRun(opts *DeleteAssetOptions) error { return fmt.Errorf("asset %s not found in release %s", opts.AssetName, release.TagName) } - err = deleteAsset(httpClient, assetURL) + err = deleteAsset(httpClient, safeurl.NewImmutableSafeURL(assetURL)) if err != nil { return err } @@ -111,8 +112,8 @@ func deleteAssetRun(opts *DeleteAssetOptions) error { return nil } -func deleteAsset(httpClient *http.Client, assetURL string) error { - req, err := http.NewRequest("DELETE", assetURL, nil) +func deleteAsset(httpClient *http.Client, assetURL safeurl.SafeURL) error { + req, err := http.NewRequest("DELETE", assetURL.String(), nil) if err != nil { return err } diff --git a/pkg/cmd/release/delete/delete.go b/pkg/cmd/release/delete/delete.go index 622b188934b..108ebae7ece 100644 --- a/pkg/cmd/release/delete/delete.go +++ b/pkg/cmd/release/delete/delete.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -92,7 +93,7 @@ func deleteRun(opts *DeleteOptions) error { } } - err = deleteRelease(httpClient, release.APIURL) + err = deleteRelease(httpClient, safeurl.NewImmutableSafeURL(release.APIURL)) if err != nil { return err } @@ -121,8 +122,8 @@ func deleteRun(opts *DeleteOptions) error { return nil } -func deleteRelease(httpClient *http.Client, releaseURL string) error { - req, err := http.NewRequest("DELETE", releaseURL, nil) +func deleteRelease(httpClient *http.Client, releaseURL safeurl.SafeURL) error { + req, err := http.NewRequest("DELETE", releaseURL.String(), nil) if err != nil { return err } @@ -140,10 +141,11 @@ func deleteRelease(httpClient *http.Client, releaseURL string) error { } func deleteTag(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) error { - path := fmt.Sprintf("repos/%s/%s/git/refs/tags/%s", baseRepo.RepoOwner(), baseRepo.RepoName(), tagName) - url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path - - req, err := http.NewRequest("DELETE", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "git", "refs", fmt.Sprintf("tags/%s", tagName)) + if err != nil { + return err + } + req, err := http.NewRequest("DELETE", url.String(), nil) if err != nil { return err } diff --git a/pkg/cmd/release/delete/delete_test.go b/pkg/cmd/release/delete/delete_test.go index 2787f247be1..13904ff3501 100644 --- a/pkg/cmd/release/delete/delete_test.go +++ b/pkg/cmd/release/delete/delete_test.go @@ -210,7 +210,7 @@ func Test_deleteRun(t *testing.T) { }`) fakeHTTP.Register(httpmock.REST("DELETE", "repos/OWNER/REPO/releases/23456"), httpmock.StatusStringResponse(204, "")) - fakeHTTP.Register(httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/tags/v1.2.3"), httpmock.StatusStringResponse(204, "")) + fakeHTTP.Register(httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/tags%2Fv1.2.3"), httpmock.StatusStringResponse(204, "")) rs, teardown := run.Stub() defer teardown(t) diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index e86b36a41d7..688d94c1472 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -17,6 +17,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -38,6 +39,8 @@ type DownloadOptions struct { Concurrency int ArchiveType string + + AllowEscapeSequences bool } func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command { @@ -110,6 +113,7 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr cmd.Flags().StringVarP(&opts.ArchiveType, "archive", "A", "", "Download the source code archive in the specified `format` (zip or tar.gz)") cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing files of the same name") cmd.Flags().BoolVar(&opts.SkipExisting, "skip-existing", false, "Skip downloading when files of the same name exist") + cmd.Flags().BoolVar(&opts.AllowEscapeSequences, "allow-escape-sequences", false, "Allow printing terminal escape sequences when writing an asset to standard output") cmdutil.DisableAuthCheck(cmd) @@ -214,15 +218,31 @@ func downloadRun(opts *DownloadOptions) error { return fmt.Errorf("unable to write more than one asset with `--output`, got %d assets", len(toDownload)) } + // An asset written to standard output is external content. It funnels through + // ContentOut so the sink is auditable; the safety decision (refuse binary bound + // for a terminal or escape sequences in text, unless --allow-escape-sequences) + // is made per copy below. Writing to a file keeps the raw bytes. + opts.IO.SetContentSanitization(false) + dest := destinationWriter{ file: opts.OutputFile, dir: opts.Destination, skipExisting: opts.SkipExisting, overwrite: opts.OverwriteExisting, - stdout: opts.IO.Out, + stdout: opts.IO.ContentOut, + allowEscapes: opts.AllowEscapeSequences, + isTTY: opts.IO.IsStdoutTTY(), + } + + targets := make([]downloadTarget, len(toDownload)) + for i, a := range toDownload { + targets[i] = downloadTarget{ + url: safeurl.NewImmutableSafeURL(a.APIURL), + name: a.Name, + } } - return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive, opts.IO) + return downloadAssets(&dest, httpClient, targets, opts.Concurrency, isArchive, opts.IO) } func matchAny(patterns []string, name string) bool { @@ -234,12 +254,17 @@ func matchAny(patterns []string, name string) bool { return false } -func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool, io *iostreams.IOStreams) error { +type downloadTarget struct { + url safeurl.SafeURL + name string +} + +func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []downloadTarget, numWorkers int, isArchive bool, io *iostreams.IOStreams) error { if numWorkers == 0 { return errors.New("the number of concurrent workers needs to be greater than 0") } - jobs := make(chan shared.ReleaseAsset, len(toDownload)) + jobs := make(chan downloadTarget, len(toDownload)) results := make(chan error, len(toDownload)) if len(toDownload) < numWorkers { @@ -249,8 +274,8 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload for w := 1; w <= numWorkers; w++ { go func() { for a := range jobs { - io.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading %s", a.Name)) - results <- downloadAsset(dest, httpClient, a.APIURL, a.Name, isArchive) + io.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading %s", a.name)) + results <- downloadAsset(dest, httpClient, a.url, a.name, isArchive) } }() } @@ -272,12 +297,12 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload return downloadError } -func downloadAsset(dest *destinationWriter, httpClient *http.Client, assetURL, fileName string, isArchive bool) error { +func downloadAsset(dest *destinationWriter, httpClient *http.Client, assetURL safeurl.SafeURL, fileName string, isArchive bool) error { if err := dest.Check(fileName); err != nil { return err } - req, err := http.NewRequest("GET", assetURL, nil) + req, err := http.NewRequest("GET", assetURL.String(), nil) if err != nil { return err } @@ -347,6 +372,8 @@ type destinationWriter struct { skipExisting bool overwrite bool stdout io.Writer + allowEscapes bool + isTTY bool } func (w destinationWriter) makePath(name string) string { @@ -389,7 +416,16 @@ func (w destinationWriter) check(fp string) error { func (w destinationWriter) Copy(name string, r io.Reader) (copyErr error) { fp := w.makePath(name) if fp == "-" { - _, copyErr = io.Copy(w.stdout, r) + if w.allowEscapes { + _, copyErr = io.Copy(w.stdout, r) + return + } + copyErr = iostreams.CopyGuardedContent(w.stdout, r, w.isTTY) + if binErr, ok := errors.AsType[iostreams.BinaryTerminalError](copyErr); ok { + copyErr = fmt.Errorf("%w; use `--output` to save it to a file, or pass --allow-escape-sequences to output it anyway", binErr) + } else if errors.Is(copyErr, iostreams.ErrEscapeSequence) { + copyErr = errors.New("the asset contains terminal escape sequences; use `--output` to save it to a file, or pass --allow-escape-sequences to output it anyway") + } return } if copyErr = w.check(fp); copyErr != nil { diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index 549ae62d59b..855380c3856 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -488,6 +488,129 @@ func Test_downloadRun(t *testing.T) { wantStdout: `1234`, wantStderr: ``, }, + { + name: "download single asset to standard output refuses escape sequences on a TTY", + isTTY: true, + opts: DownloadOptions{ + OutputFile: "-", + TagName: "v1.2.3", + Destination: "", + Concurrency: 2, + FilePatterns: []string{"*windows-32bit.zip"}, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse("\x1b[31mred\x1b[m")) + }, + wantErr: "the asset contains terminal escape sequences; use `--output` to save it to a file, or pass --allow-escape-sequences to output it anyway", + }, + { + name: "download single asset to standard output passes escape sequences through with --allow-escape-sequences", + isTTY: true, + opts: DownloadOptions{ + OutputFile: "-", + TagName: "v1.2.3", + Destination: "", + Concurrency: 2, + FilePatterns: []string{"*windows-32bit.zip"}, + AllowEscapeSequences: true, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse("\x1b[31mred\x1b[m")) + }, + wantStdout: "\x1b[31mred\x1b[m", + wantStderr: ``, + }, + { + name: "download single asset to standard output refuses escape sequences when piped", + isTTY: false, + opts: DownloadOptions{ + OutputFile: "-", + TagName: "v1.2.3", + Destination: "", + Concurrency: 2, + FilePatterns: []string{"*windows-32bit.zip"}, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse("\x1b[31mred\x1b[m")) + }, + wantErr: "the asset contains terminal escape sequences; use `--output` to save it to a file, or pass --allow-escape-sequences to output it anyway", + }, + { + name: "download single binary asset to standard output streams raw when piped", + isTTY: false, + opts: DownloadOptions{ + OutputFile: "-", + TagName: "v1.2.3", + Destination: "", + Concurrency: 2, + FilePatterns: []string{"*windows-32bit.zip"}, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 24, + "url": "https://api.github.com/assets/1234" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(string(append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...)))) + }, + wantStdout: string(append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...)), + wantStderr: ``, + }, + { + name: "download single binary asset to standard output is refused on a TTY", + isTTY: true, + opts: DownloadOptions{ + OutputFile: "-", + TagName: "v1.2.3", + Destination: "", + Concurrency: 2, + FilePatterns: []string{"*windows-32bit.zip"}, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 24, + "url": "https://api.github.com/assets/1234" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(string(append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...)))) + }, + wantErr: "refusing to output binary content (image/png) to the terminal; use `--output` to save it to a file, or pass --allow-escape-sequences to output it anyway", + }, { name: "draft release with null tarball_url and zipball_url", isTTY: true, diff --git a/pkg/cmd/release/edit/http.go b/pkg/cmd/release/edit/http.go index bf310da6053..291123ad3ea 100644 --- a/pkg/cmd/release/edit/http.go +++ b/pkg/cmd/release/edit/http.go @@ -6,10 +6,12 @@ import ( "fmt" "io" "net/http" + "strconv" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/shurcooL/githubv4" ) @@ -20,9 +22,11 @@ func editRelease(httpClient *http.Client, repo ghrepo.Interface, releaseID int64 return nil, err } - path := fmt.Sprintf("repos/%s/%s/releases/%d", repo.RepoOwner(), repo.RepoName(), releaseID) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(bodyBytes)) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", strconv.FormatInt(releaseID, 10)) + if err != nil { + return nil, err + } + req, err := http.NewRequest("PATCH", url.String(), bytes.NewBuffer(bodyBytes)) if err != nil { return nil, err } diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 420b83b366b..74bf06657a1 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "reflect" + "strconv" "strings" "testing" "time" @@ -15,6 +16,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/httpmock" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -136,8 +138,11 @@ type fetchResult struct { } func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (string, error) { - path := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", repo.RepoOwner(), repo.RepoName(), tagName) - req, err := http.NewRequestWithContext(ctx, "GET", ghinstance.RESTPrefix(repo.RepoHost())+path, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "git", "ref", fmt.Sprintf("tags/%s", tagName)) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil) if err != nil { return "", err } @@ -185,13 +190,17 @@ func DigestAlgForRef(digest string) string { // FetchRelease finds a published repository release by its tagName, or a draft release by its pending tag name. func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { + publishedURL, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", "tags", tagName) + if err != nil { + return nil, err + } + cc, cancel := context.WithCancel(ctx) results := make(chan fetchResult, 2) // published release lookup go func() { - path := fmt.Sprintf("repos/%s/%s/releases/tags/%s", repo.RepoOwner(), repo.RepoName(), tagName) - release, err := fetchReleasePath(cc, httpClient, repo.RepoHost(), path) + release, err := fetchReleasePath(cc, httpClient, publishedURL) results <- fetchResult{release: release, error: err} }() @@ -226,8 +235,11 @@ func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Inte // FetchLatestRelease finds the latest published release for a repository. func FetchLatestRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface) (*Release, error) { - path := fmt.Sprintf("repos/%s/%s/releases/latest", repo.RepoOwner(), repo.RepoName()) - return fetchReleasePath(ctx, httpClient, repo.RepoHost(), path) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", "latest") + if err != nil { + return nil, err + } + return fetchReleasePath(ctx, httpClient, url) } // fetchDraftRelease returns the first draft release that has tagName as its pending tag. @@ -259,12 +271,15 @@ func fetchDraftRelease(ctx context.Context, httpClient *http.Client, repo ghrepo // Then, use REST to get information about the draft release. In theory, we could have fetched // all the necessary information via GraphQL, but REST is safer for backwards compatibility. - path := fmt.Sprintf("repos/%s/%s/releases/%d", repo.RepoOwner(), repo.RepoName(), query.Repository.Release.DatabaseID) - return fetchReleasePath(ctx, httpClient, repo.RepoHost(), path) + path, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", strconv.FormatInt(query.Repository.Release.DatabaseID, 10)) + if err != nil { + return nil, err + } + return fetchReleasePath(ctx, httpClient, path) } -func fetchReleasePath(ctx context.Context, httpClient *http.Client, host string, p string) (*Release, error) { - req, err := http.NewRequestWithContext(ctx, "GET", ghinstance.RESTPrefix(host)+p, nil) +func fetchReleasePath(ctx context.Context, httpClient *http.Client, url safeurl.SafeURL) (*Release, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil) if err != nil { return nil, err } @@ -312,7 +327,7 @@ func StubFetchRelease(t *testing.T, reg *httpmock.Registry, owner, repoName, tag } func StubFetchRefSHA(t *testing.T, reg *httpmock.Registry, owner, repoName, tagName, sha string) { - path := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repoName, tagName) + path := fmt.Sprintf("repos/%s/%s/git/ref/tags%%2F%s", owner, repoName, tagName) reg.Register( httpmock.REST("GET", path), httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": "%s"}}`, sha)), diff --git a/pkg/cmd/release/shared/fetch_test.go b/pkg/cmd/release/shared/fetch_test.go index 0720b876f6f..e68278f1ed1 100644 --- a/pkg/cmd/release/shared/fetch_test.go +++ b/pkg/cmd/release/shared/fetch_test.go @@ -42,7 +42,7 @@ func TestFetchRefSHA(t *testing.T) { tagName: "v1.2.3", responseStatus: 500, responseMessage: `arbitrary error"`, - errorMessage: "HTTP 500: arbitrary error\" (https://api.github.com/repos/owner/repo/git/ref/tags/v1.2.3)", + errorMessage: "HTTP 500: arbitrary error\" (https://api.github.com/repos/owner/repo/git/ref/tags%2Fv1.2.3)", }, { name: "malformed JSON with 200", @@ -61,7 +61,7 @@ func TestFetchRefSHA(t *testing.T) { repo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - path := "repos/owner/repo/git/ref/tags/" + tt.tagName + path := "repos/owner/repo/git/ref/tags%2F" + tt.tagName if tt.responseStatus == 404 || tt.responseStatus == 500 { fakeHTTP.Register( httpmock.REST("GET", path), diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go index ab7533320e8..9307c8c01c2 100644 --- a/pkg/cmd/release/shared/upload.go +++ b/pkg/cmd/release/shared/upload.go @@ -15,6 +15,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "golang.org/x/sync/errgroup" ) @@ -33,7 +34,7 @@ type AssetForUpload struct { MIMEType string Open func() (io.ReadCloser, error) - ExistingURL string + ExistingURL safeurl.SafeURL } func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) { @@ -111,7 +112,7 @@ func fileExt(fn string) string { return path.Ext(fn) } -func ConcurrentUpload(httpClient httpDoer, uploadURL string, numWorkers int, assets []*AssetForUpload) error { +func ConcurrentUpload(httpClient httpDoer, uploadURL safeurl.SafeURL, numWorkers int, assets []*AssetForUpload) error { if numWorkers == 0 { return errors.New("the number of concurrent workers needs to be greater than 0") } @@ -142,8 +143,8 @@ func shouldRetry(err error) bool { // Allow injecting backoff interval in tests. var retryInterval = time.Millisecond * 200 -func uploadWithDelete(ctx context.Context, httpClient httpDoer, uploadURL string, a AssetForUpload) error { - if a.ExistingURL != "" { +func uploadWithDelete(ctx context.Context, httpClient httpDoer, uploadURL safeurl.SafeURL, a AssetForUpload) error { + if a.ExistingURL != nil && a.ExistingURL.String() != "" { if err := deleteAsset(ctx, httpClient, a.ExistingURL); err != nil { return err } @@ -158,8 +159,8 @@ func uploadWithDelete(ctx context.Context, httpClient httpDoer, uploadURL string }, backoff.WithContext(backoff.WithMaxRetries(bo, 3), ctx)) } -func uploadAsset(ctx context.Context, httpClient httpDoer, uploadURL string, asset AssetForUpload) (*ReleaseAsset, error) { - u, err := url.Parse(uploadURL) +func uploadAsset(ctx context.Context, httpClient httpDoer, uploadURL safeurl.SafeURL, asset AssetForUpload) (*ReleaseAsset, error) { + u, err := url.Parse(uploadURL.String()) if err != nil { return nil, err } @@ -168,13 +169,16 @@ func uploadAsset(ctx context.Context, httpClient httpDoer, uploadURL string, ass params.Set("label", asset.Label) u.RawQuery = params.Encode() + // Since u is derived from uploadURL, an already-trusted safeurl.SafeURL, the resulting URL is safe to declare as such. + safeURL := safeurl.NewImmutableSafeURL(u.String()) + f, err := asset.Open() if err != nil { return nil, err } defer f.Close() - req, err := http.NewRequestWithContext(ctx, "POST", u.String(), f) + req, err := http.NewRequestWithContext(ctx, "POST", safeURL.String(), f) if err != nil { return nil, err } @@ -202,8 +206,8 @@ func uploadAsset(ctx context.Context, httpClient httpDoer, uploadURL string, ass return &newAsset, nil } -func deleteAsset(ctx context.Context, httpClient httpDoer, assetURL string) error { - req, err := http.NewRequestWithContext(ctx, "DELETE", assetURL, nil) +func deleteAsset(ctx context.Context, httpClient httpDoer, assetURL safeurl.SafeURL) error { + req, err := http.NewRequestWithContext(ctx, "DELETE", assetURL.String(), nil) if err != nil { return err } diff --git a/pkg/cmd/release/shared/upload_test.go b/pkg/cmd/release/shared/upload_test.go index 26bed11c017..8271fa6ae6e 100644 --- a/pkg/cmd/release/shared/upload_test.go +++ b/pkg/cmd/release/shared/upload_test.go @@ -7,6 +7,8 @@ import ( "io" "net/http" "testing" + + "github.com/cli/cli/v2/internal/safeurl" ) func Test_typeForFilename(t *testing.T) { @@ -97,7 +99,7 @@ func Test_uploadWithDelete_retry(t *testing.T) { Body: io.NopCloser(bytes.NewBufferString(`{}`)), }, nil }) - err := uploadWithDelete(ctx, client, "http://example.com/upload", AssetForUpload{ + err := uploadWithDelete(ctx, client, safeurl.NewImmutableSafeURL("http://example.com/upload"), AssetForUpload{ Name: "asset", Label: "", Size: 8, diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go index 827dcdc6421..35b10860d75 100644 --- a/pkg/cmd/release/upload/upload.go +++ b/pkg/cmd/release/upload/upload.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -90,17 +91,12 @@ func uploadRun(opts *UploadOptions) error { return err } - uploadURL := release.UploadURL - if idx := strings.IndexRune(uploadURL, '{'); idx > 0 { - uploadURL = uploadURL[:idx] - } - var existingNames []string for _, a := range opts.Assets { sanitizedFileName := sanitizeFileName(a.Name) for _, ea := range release.Assets { if ea.Name == sanitizedFileName { - a.ExistingURL = ea.APIURL + a.ExistingURL = safeurl.NewImmutableSafeURL(ea.APIURL) existingNames = append(existingNames, ea.Name) break } @@ -111,8 +107,13 @@ func uploadRun(opts *UploadOptions) error { return fmt.Errorf("asset under the same name already exists: %v", existingNames) } + uploadURL := release.UploadURL + if idx := strings.IndexRune(uploadURL, '{'); idx > 0 { + uploadURL = uploadURL[:idx] + } + opts.IO.StartProgressIndicator() - err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets) + err = shared.ConcurrentUpload(httpClient, safeurl.NewImmutableSafeURL(uploadURL), opts.Concurrency, opts.Assets) opts.IO.StopProgressIndicator() if err != nil { return err diff --git a/pkg/cmd/repo/autolink/create/http.go b/pkg/cmd/repo/autolink/create/http.go index 5f187319f88..1de86e42cb8 100644 --- a/pkg/cmd/repo/autolink/create/http.go +++ b/pkg/cmd/repo/autolink/create/http.go @@ -4,12 +4,12 @@ import ( "bytes" "encoding/json" "errors" - "fmt" "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" ) @@ -24,8 +24,10 @@ type AutolinkCreateRequest struct { } func (a *AutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) { - path := fmt.Sprintf("repos/%s/%s/autolinks", repo.RepoOwner(), repo.RepoName()) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "autolinks") + if err != nil { + return nil, err + } requestByte, err := json.Marshal(request) if err != nil { @@ -33,7 +35,7 @@ func (a *AutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRe } requestBody := bytes.NewReader(requestByte) - req, err := http.NewRequest(http.MethodPost, url, requestBody) + req, err := http.NewRequest(http.MethodPost, url.String(), requestBody) if err != nil { return nil, err } diff --git a/pkg/cmd/repo/autolink/delete/http.go b/pkg/cmd/repo/autolink/delete/http.go index d6bc53e840f..ed35d6328df 100644 --- a/pkg/cmd/repo/autolink/delete/http.go +++ b/pkg/cmd/repo/autolink/delete/http.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) type AutolinkDeleter struct { @@ -14,9 +15,11 @@ type AutolinkDeleter struct { } func (a *AutolinkDeleter) Delete(repo ghrepo.Interface, id string) error { - path := fmt.Sprintf("repos/%s/%s/autolinks/%s", repo.RepoOwner(), repo.RepoName(), id) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest(http.MethodDelete, url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "autolinks", id) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodDelete, url.String(), nil) if err != nil { return err } @@ -28,7 +31,7 @@ func (a *AutolinkDeleter) Delete(repo ghrepo.Interface, id string) error { defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("error deleting autolink: HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/%s)", path) + return fmt.Errorf("error deleting autolink: HTTP 404: Perhaps you are missing admin rights to the repository? (%s)", url) } else if resp.StatusCode > 299 { return api.HandleHTTPError(resp) } diff --git a/pkg/cmd/repo/autolink/list/http.go b/pkg/cmd/repo/autolink/list/http.go index cdb8e621c61..210495c7613 100644 --- a/pkg/cmd/repo/autolink/list/http.go +++ b/pkg/cmd/repo/autolink/list/http.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" ) @@ -16,9 +17,11 @@ type AutolinkLister struct { } func (a *AutolinkLister) List(repo ghrepo.Interface) ([]shared.Autolink, error) { - path := fmt.Sprintf("repos/%s/%s/autolinks", repo.RepoOwner(), repo.RepoName()) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest(http.MethodGet, url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "autolinks") + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, url.String(), nil) if err != nil { return nil, err } @@ -30,7 +33,7 @@ func (a *AutolinkLister) List(repo ghrepo.Interface) ([]shared.Autolink, error) defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("error getting autolinks: HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/%s)", path) + return nil, fmt.Errorf("error getting autolinks: HTTP 404: Perhaps you are missing admin rights to the repository? (%s)", url) } else if resp.StatusCode > 299 { return nil, api.HandleHTTPError(resp) } diff --git a/pkg/cmd/repo/autolink/view/http.go b/pkg/cmd/repo/autolink/view/http.go index cc5638613e4..a604fb8f24d 100644 --- a/pkg/cmd/repo/autolink/view/http.go +++ b/pkg/cmd/repo/autolink/view/http.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" ) @@ -16,9 +17,11 @@ type AutolinkViewer struct { } func (a *AutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolink, error) { - path := fmt.Sprintf("repos/%s/%s/autolinks/%s", repo.RepoOwner(), repo.RepoName(), id) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest(http.MethodGet, url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "autolinks", id) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, url.String(), nil) if err != nil { return nil, err } @@ -30,7 +33,7 @@ func (a *AutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolin defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/%s)", path) + return nil, fmt.Errorf("HTTP 404: Perhaps you are missing admin rights to the repository? (%s)", url) } else if resp.StatusCode > 299 { return nil, api.HandleHTTPError(resp) } diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go index 725fc48c555..8a93814ca98 100644 --- a/pkg/cmd/repo/create/http.go +++ b/pkg/cmd/repo/create/http.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/safeurl" "github.com/shurcooL/githubv4" ) @@ -186,9 +187,15 @@ func repoCreate(client *http.Client, hostname string, input repoCreateInput) (*a InitReadme: input.InitReadme, } - path := "user/repos" + path, err := safeurl.JoinPath("user", "repos") + if err != nil { + return nil, err + } if isOrg { - path = fmt.Sprintf("orgs/%s/repos", input.OwnerLogin) + path, err = safeurl.JoinPath("orgs", input.OwnerLogin, "repos") + if err != nil { + return nil, err + } inputv3.Visibility = strings.ToLower(input.Visibility) } @@ -254,7 +261,11 @@ func (r *ownerResponse) IsOrganization() bool { func resolveOwner(client *api.Client, hostname, orgName string) (*ownerResponse, error) { var response ownerResponse - err := client.REST(hostname, "GET", fmt.Sprintf("users/%s", orgName), nil, &response) + u, err := safeurl.JoinPath("users", orgName) + if err != nil { + return nil, err + } + err = client.REST(hostname, "GET", u.String(), nil, &response) return &response, err } @@ -268,7 +279,11 @@ type teamResponse struct { func resolveOrganizationTeam(client *api.Client, hostname, orgName, teamSlug string) (*teamResponse, error) { var response teamResponse - err := client.REST(hostname, "GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response) + u, err := safeurl.JoinPath("orgs", orgName, "teams", teamSlug) + if err != nil { + return nil, err + } + err = client.REST(hostname, "GET", u.String(), nil, &response) return &response, err } diff --git a/pkg/cmd/repo/credits/credits.go b/pkg/cmd/repo/credits/credits.go index a26b6a7312a..42c5766d7ed 100644 --- a/pkg/cmd/repo/credits/credits.go +++ b/pkg/cmd/repo/credits/credits.go @@ -15,6 +15,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" @@ -142,9 +143,12 @@ func creditsRun(opts *CreditsOptions) error { result := Result{} body := bytes.NewBufferString("") - path := fmt.Sprintf("repos/%s/%s/contributors", baseRepo.RepoOwner(), baseRepo.RepoName()) + path, err := safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "contributors") + if err != nil { + return err + } - err = client.REST(baseRepo.RepoHost(), "GET", path, body, &result) + err = client.REST(baseRepo.RepoHost(), "GET", path.String(), body, &result) if err != nil { return err } diff --git a/pkg/cmd/repo/delete/http.go b/pkg/cmd/repo/delete/http.go index 23930e8e054..faffa006e3d 100644 --- a/pkg/cmd/repo/delete/http.go +++ b/pkg/cmd/repo/delete/http.go @@ -1,12 +1,12 @@ package delete import ( - "fmt" "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) func deleteRepo(client *http.Client, repo ghrepo.Interface) error { @@ -16,11 +16,12 @@ func deleteRepo(client *http.Client, repo ghrepo.Interface) error { return http.ErrUseLastResponse } - url := fmt.Sprintf("%srepos/%s", - ghinstance.RESTPrefix(repo.RepoHost()), - ghrepo.FullName(repo)) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName()) + if err != nil { + return err + } - request, err := http.NewRequest("DELETE", url, nil) + request, err := http.NewRequest("DELETE", url.String(), nil) if err != nil { return err } diff --git a/pkg/cmd/repo/deploy-key/add/http.go b/pkg/cmd/repo/deploy-key/add/http.go index d2a933f5fa0..5111049c8ed 100644 --- a/pkg/cmd/repo/deploy-key/add/http.go +++ b/pkg/cmd/repo/deploy-key/add/http.go @@ -3,18 +3,20 @@ package add import ( "bytes" "encoding/json" - "fmt" "io" "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) func uploadDeployKey(httpClient *http.Client, repo ghrepo.Interface, keyFile io.Reader, title string, isWritable bool) error { - path := fmt.Sprintf("repos/%s/%s/keys", repo.RepoOwner(), repo.RepoName()) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "keys") + if err != nil { + return err + } keyBytes, err := io.ReadAll(keyFile) if err != nil { @@ -32,7 +34,7 @@ func uploadDeployKey(httpClient *http.Client, repo ghrepo.Interface, keyFile io. return err } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) + req, err := http.NewRequest("POST", url.String(), bytes.NewBuffer(payloadBytes)) if err != nil { return err } diff --git a/pkg/cmd/repo/deploy-key/delete/http.go b/pkg/cmd/repo/deploy-key/delete/http.go index 53de349fcbd..117ce697a29 100644 --- a/pkg/cmd/repo/deploy-key/delete/http.go +++ b/pkg/cmd/repo/deploy-key/delete/http.go @@ -1,20 +1,22 @@ package delete import ( - "fmt" "io" "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) func deleteDeployKey(httpClient *http.Client, repo ghrepo.Interface, id string) error { - path := fmt.Sprintf("repos/%s/%s/keys/%s", repo.RepoOwner(), repo.RepoName(), id) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "keys", id) + if err != nil { + return err + } - req, err := http.NewRequest("DELETE", url, nil) + req, err := http.NewRequest("DELETE", url.String(), nil) if err != nil { return err } diff --git a/pkg/cmd/repo/deploy-key/list/http.go b/pkg/cmd/repo/deploy-key/list/http.go index 134b128dc4e..391d6bbe17d 100644 --- a/pkg/cmd/repo/deploy-key/list/http.go +++ b/pkg/cmd/repo/deploy-key/list/http.go @@ -2,7 +2,6 @@ package list import ( "encoding/json" - "fmt" "io" "net/http" "time" @@ -10,6 +9,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) type deployKey struct { @@ -21,9 +21,12 @@ type deployKey struct { } func repoKeys(httpClient *http.Client, repo ghrepo.Interface) ([]deployKey, error) { - path := fmt.Sprintf("repos/%s/%s/keys?per_page=100", repo.RepoOwner(), repo.RepoName()) - url := ghinstance.RESTPrefix(repo.RepoHost()) + path - req, err := http.NewRequest("GET", url, nil) + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "keys") + if err != nil { + return nil, err + } + u.SetQuery("per_page", "100") + req, err := http.NewRequest("GET", u.String(), nil) if err != nil { return nil, err } diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index aff7a5fe188..c215f182ccc 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -16,6 +16,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -298,7 +299,10 @@ func editRun(ctx context.Context, opts *EditOptions) error { } } - apiPath := fmt.Sprintf("repos/%s/%s", repo.RepoOwner(), repo.RepoName()) + apiPath, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName()) + if err != nil { + return err + } body := &bytes.Buffer{} enc := json.NewEncoder(body) @@ -341,7 +345,7 @@ func editRun(ctx context.Context, opts *EditOptions) error { }) } - err := g.Wait() + err = g.Wait() if err != nil { return err } @@ -563,8 +567,11 @@ func parseTopics(s string) []string { } func getTopics(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface) ([]string, error) { - apiPath := fmt.Sprintf("repos/%s/%s/topics", repo.RepoOwner(), repo.RepoName()) - req, err := http.NewRequestWithContext(ctx, "GET", ghinstance.RESTPrefix(repo.RepoHost())+apiPath, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "topics") + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil) if err != nil { return nil, err } @@ -601,8 +608,11 @@ func setTopics(ctx context.Context, httpClient *http.Client, repo ghrepo.Interfa return err } - apiPath := fmt.Sprintf("repos/%s/%s/topics", repo.RepoOwner(), repo.RepoName()) - req, err := http.NewRequestWithContext(ctx, "PUT", ghinstance.RESTPrefix(repo.RepoHost())+apiPath, body) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "topics") + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, "PUT", url.String(), body) if err != nil { return err } diff --git a/pkg/cmd/repo/garden/http.go b/pkg/cmd/repo/garden/http.go index d968296f497..903de787632 100644 --- a/pkg/cmd/repo/garden/http.go +++ b/pkg/cmd/repo/garden/http.go @@ -3,14 +3,15 @@ package garden import ( "encoding/json" "errors" - "fmt" "io" "net/http" + "strconv" "strings" "time" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) func getCommits(client *http.Client, repo ghrepo.Interface, maxCommits int) ([]*Commit, error) { @@ -25,8 +26,14 @@ func getCommits(client *http.Client, repo ghrepo.Interface, maxCommits int) ([]* commits := []*Commit{} - pathF := func(page int) string { - return fmt.Sprintf("repos/%s/%s/commits?per_page=100&page=%d", repo.RepoOwner(), repo.RepoName(), page) + pathF := func(page int) (*safeurl.MutableSafeURL, error) { + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "commits") + if err != nil { + return nil, err + } + u.SetQuery("per_page", "100") + u.SetQuery("page", strconv.Itoa(page)) + return u, nil } page := 1 @@ -36,7 +43,11 @@ func getCommits(client *http.Client, repo ghrepo.Interface, maxCommits int) ([]* break } result := Result{} - links, err := getResponse(client, repo.RepoHost(), pathF(page), &result) + path, err := pathF(page) + if err != nil { + return nil, err + } + links, err := getResponse(client, path, &result) if err != nil { return nil, err } @@ -69,9 +80,8 @@ func getCommits(client *http.Client, repo ghrepo.Interface, maxCommits int) ([]* // getResponse performs the API call and returns the response's link header values. // If the "Link" header is missing, the returned slice will be nil. -func getResponse(client *http.Client, host, path string, data interface{}) ([]string, error) { - url := ghinstance.RESTPrefix(host) + path - req, err := http.NewRequest("GET", url, nil) +func getResponse(client *http.Client, url safeurl.SafeURL, data interface{}) ([]string, error) { + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return nil, err } diff --git a/pkg/cmd/repo/read-file/http.go b/pkg/cmd/repo/read-file/http.go index e703ffe8733..20c1ed0e9f1 100644 --- a/pkg/cmd/repo/read-file/http.go +++ b/pkg/cmd/repo/read-file/http.go @@ -6,12 +6,12 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) // repoFile is the resolved file content and metadata for a single path. @@ -83,9 +83,12 @@ type contentsResponse struct { // It requests the unified object media type so directories, files, symlinks, and // submodules all come back as a single JSON object distinguished by the type field. func fetchContent(httpClient *http.Client, repo ghrepo.Interface, filePath, ref string) (*contentsResponse, error) { - apiPath := contentsAPIPath(repo, filePath, ref) + apiPath, err := contentsAPIPath(repo, filePath, ref) + if err != nil { + return nil, err + } - req, err := http.NewRequest("GET", apiPath, nil) + req, err := http.NewRequest("GET", apiPath.String(), nil) if err != nil { return nil, err } @@ -171,9 +174,12 @@ func fetchFile(httpClient *http.Client, repo ghrepo.Interface, filePath, ref str // fetchRawFile retrieves the raw bytes of a file, used for files larger than the // 1 MB inline content limit of the Contents API. func fetchRawFile(httpClient *http.Client, repo ghrepo.Interface, filePath, ref string) ([]byte, error) { - apiPath := contentsAPIPath(repo, filePath, ref) + apiPath, err := contentsAPIPath(repo, filePath, ref) + if err != nil { + return nil, err + } - req, err := http.NewRequest("GET", apiPath, nil) + req, err := http.NewRequest("GET", apiPath.String(), nil) if err != nil { return nil, err } @@ -193,16 +199,15 @@ func fetchRawFile(httpClient *http.Client, repo ghrepo.Interface, filePath, ref } // contentsAPIPath builds the absolute Contents API URL for a path and optional ref. -func contentsAPIPath(repo ghrepo.Interface, filePath, ref string) string { +func contentsAPIPath(repo ghrepo.Interface, filePath, ref string) (safeurl.SafeURL, error) { // The Contents API accepts a fully percent-encoded path, including path separators // encoded as %2F, so spaces and other special characters are handled transparently. - p := fmt.Sprintf("%srepos/%s/%s/contents/%s", - ghinstance.RESTPrefix(repo.RepoHost()), - repo.RepoOwner(), repo.RepoName(), - url.PathEscape(strings.TrimPrefix(filePath, "/")), - ) + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "contents", strings.TrimPrefix(filePath, "/")) + if err != nil { + return nil, err + } if ref != "" { - p += "?ref=" + url.QueryEscape(ref) + u.SetQuery("ref", ref) } - return p + return u, nil } diff --git a/pkg/cmd/repo/read-file/read_file.go b/pkg/cmd/repo/read-file/read_file.go index faf0a95a90c..5940236cf72 100644 --- a/pkg/cmd/repo/read-file/read_file.go +++ b/pkg/cmd/repo/read-file/read_file.go @@ -1,7 +1,6 @@ package readfile import ( - "bytes" "errors" "fmt" "net/http" @@ -179,18 +178,24 @@ func readFileRun(opts *ReadFileOptions) error { return nil } - if mime, ok := binaryContentType(file.Content); ok { + // read-file does its own escape-sequence guarding below, so it writes raw + // bytes through ContentOut in passthrough mode. Leaving sanitization on + // would corrupt binary files and strip the escapes that + // --allow-escape-sequences explicitly allows. + opts.IO.SetContentSanitization(false) + + if mime, ok := iostreams.BinaryContentType(file.Content); ok { if opts.IO.IsStdoutTTY() { return fmt.Errorf("binary file (%s, %s); use --output to save to a file or pipe stdout", mime, text.FormatSize(int64(file.Size))) } - _, err = opts.IO.Out.Write(file.Content) + _, err = opts.IO.ContentOut.Write(file.Content) return err } // Refuse terminal escape sequences unless --allow-escape-sequences, in both TTY and non-TTY modes, // so a malicious file cannot manipulate a downstream terminal. - if !opts.AllowEscapeSequences && containsEscapeSequence(file.Content) { + if !opts.AllowEscapeSequences && iostreams.ContainsEscapeSequence(file.Content) { return errors.New("file contains terminal escape sequences; use --allow-escape-sequences to read anyway") } @@ -201,7 +206,7 @@ func readFileRun(opts *ReadFileOptions) error { defer opts.IO.StopPager() } - _, err = opts.IO.Out.Write(file.Content) + _, err = opts.IO.ContentOut.Write(file.Content) return err } @@ -297,27 +302,3 @@ func writeToOutput(file *repoFile, output string, clobber bool) (string, error) return dest, nil } - -// binaryContentType reports whether content appears to be binary and, if so, returns -// its detected MIME type. Textual content returns ("", false). -func binaryContentType(content []byte) (string, bool) { - if len(content) == 0 { - return "", false - } - - ct := http.DetectContentType(content) - if i := strings.IndexByte(ct, ';'); i >= 0 { - ct = strings.TrimSpace(ct[:i]) - } - - if strings.HasPrefix(ct, "text/") { - return "", false - } - return ct, true -} - -// containsEscapeSequence reports whether content contains an ANSI escape byte (0x1B), -// which could be used to manipulate the terminal when printed. -func containsEscapeSequence(content []byte) bool { - return bytes.IndexByte(content, 0x1B) >= 0 -} diff --git a/pkg/cmd/repo/read-file/read_file_test.go b/pkg/cmd/repo/read-file/read_file_test.go index 659d1b936e8..59d3856e40b 100644 --- a/pkg/cmd/repo/read-file/read_file_test.go +++ b/pkg/cmd/repo/read-file/read_file_test.go @@ -751,47 +751,9 @@ func Test_contentsAPIPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := contentsAPIPath(repo, tt.filePath, tt.ref) - assert.Equal(t, tt.want, got) - }) - } -} - -func Test_binaryContentType(t *testing.T) { - tests := []struct { - name string - content []byte - wantMIME string - wantBinary bool - }{ - { - name: "empty content is not binary", - content: []byte{}, - wantBinary: false, - }, - { - name: "plain text is not binary", - content: []byte("hello world\n"), - wantBinary: false, - }, - { - name: "png is binary", - content: append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...), - wantMIME: "image/png", - wantBinary: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mime, ok := binaryContentType(tt.content) - assert.Equal(t, tt.wantBinary, ok) - assert.Equal(t, tt.wantMIME, mime) + got, err := contentsAPIPath(repo, tt.filePath, tt.ref) + require.NoError(t, err) + assert.Equal(t, tt.want, got.String()) }) } } - -func Test_containsEscapeSequence(t *testing.T) { - assert.False(t, containsEscapeSequence([]byte("plain text"))) - assert.True(t, containsEscapeSequence([]byte("danger\x1b[31m"))) -} diff --git a/pkg/cmd/repo/sync/http.go b/pkg/cmd/repo/sync/http.go index 27e9a635169..86cc0468851 100644 --- a/pkg/cmd/repo/sync/http.go +++ b/pkg/cmd/repo/sync/http.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) type commit struct { @@ -25,8 +26,11 @@ type commit struct { func latestCommit(client *api.Client, repo ghrepo.Interface, branch string) (commit, error) { var response commit - path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch) - err := client.REST(repo.RepoHost(), "GET", path, nil, &response) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "git", "refs", fmt.Sprintf("heads/%s", branch)) + if err != nil { + return response, err + } + err = client.REST(repo.RepoHost(), "GET", path.String(), nil, &response) return response, err } @@ -48,9 +52,12 @@ func triggerUpstreamMerge(client *api.Client, repo ghrepo.Interface, branch stri MergeType string `json:"merge_type"` BaseBranch string `json:"base_branch"` } - path := fmt.Sprintf("repos/%s/%s/merge-upstream", repo.RepoOwner(), repo.RepoName()) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "merge-upstream") + if err != nil { + return "", err + } var httpErr api.HTTPError - if err := client.REST(repo.RepoHost(), "POST", path, &payload, &response); err != nil { + if err := client.REST(repo.RepoHost(), "POST", path.String(), &payload, &response); err != nil { if errors.As(err, &httpErr) { switch httpErr.StatusCode { case http.StatusUnprocessableEntity, http.StatusConflict: @@ -66,7 +73,10 @@ func triggerUpstreamMerge(client *api.Client, repo ghrepo.Interface, branch stri } func syncFork(client *api.Client, repo ghrepo.Interface, branch, SHA string, force bool) error { - path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "git", "refs", fmt.Sprintf("heads/%s", branch)) + if err != nil { + return err + } body := map[string]interface{}{ "sha": SHA, "force": force, @@ -76,5 +86,5 @@ func syncFork(client *api.Client, repo ghrepo.Interface, branch, SHA string, for return err } requestBody := bytes.NewReader(requestByte) - return client.REST(repo.RepoHost(), "PATCH", path, requestBody, nil) + return client.REST(repo.RepoHost(), "PATCH", path.String(), requestBody, nil) } diff --git a/pkg/cmd/repo/sync/sync_test.go b/pkg/cmd/repo/sync/sync_test.go index 60e6ae392a3..74fa10d6ea0 100644 --- a/pkg/cmd/repo/sync/sync_test.go +++ b/pkg/cmd/repo/sync/sync_test.go @@ -306,10 +306,10 @@ func Test_SyncRun(t *testing.T) { httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"), httpmock.StatusStringResponse(422, `{}`)) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), + httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads%2Ftrunk"), httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) reg.Register( - httpmock.REST("PATCH", "repos/FORKOWNER/REPO-FORK/git/refs/heads/trunk"), + httpmock.REST("PATCH", "repos/FORKOWNER/REPO-FORK/git/refs/heads%2Ftrunk"), httpmock.StringResponse(`{}`)) }, wantStdout: "✓ Synced the \"FORKOWNER:trunk\" branch from \"OWNER:trunk\"\n", @@ -395,10 +395,10 @@ func Test_SyncRun(t *testing.T) { httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"), httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`)) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), + httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads%2Ftrunk"), httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) reg.Register( - httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"), + httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads%2Ftrunk"), httpmock.StringResponse(`{}`)) }, wantStdout: "✓ Synced the \"OWNER:trunk\" branch from \"OWNER:trunk\"\n", @@ -420,10 +420,10 @@ func Test_SyncRun(t *testing.T) { httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"), httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`)) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), + httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads%2Ftrunk"), httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) reg.Register( - httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"), + httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads%2Ftrunk"), func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 422, @@ -453,10 +453,10 @@ func Test_SyncRun(t *testing.T) { httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"), httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`)) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), + httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads%2Ftrunk"), httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) reg.Register( - httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"), + httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads%2Ftrunk"), func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 422, diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go index 5988580e5c4..14aef095f82 100644 --- a/pkg/cmd/repo/view/http.go +++ b/pkg/cmd/repo/view/http.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/go-gh/v2/pkg/asciisanitizer" "golang.org/x/text/transform" ) @@ -30,7 +31,12 @@ func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string) HTMLURL string `json:"html_url"` } - err := apiClient.REST(repo.RepoHost(), "GET", getReadmePath(repo, branch), nil, &response) + readmePath, err := getReadmePath(repo, branch) + if err != nil { + return nil, err + } + + err = apiClient.REST(repo.RepoHost(), "GET", readmePath.String(), nil, &response) if err != nil { var httpError api.HTTPError if errors.As(err, &httpError) && httpError.StatusCode == 404 { @@ -56,10 +62,13 @@ func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string) }, nil } -func getReadmePath(repo ghrepo.Interface, branch string) string { - path := fmt.Sprintf("repos/%s/readme", ghrepo.FullName(repo)) +func getReadmePath(repo ghrepo.Interface, branch string) (safeurl.SafeURL, error) { + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "readme") + if err != nil { + return nil, err + } if branch != "" { - path = fmt.Sprintf("%s?ref=%s", path, branch) + path.SetQuery("ref", branch) } - return path + return path, nil } diff --git a/pkg/cmd/ruleset/check/check.go b/pkg/cmd/ruleset/check/check.go index b56476d840c..1fbbc432028 100644 --- a/pkg/cmd/ruleset/check/check.go +++ b/pkg/cmd/ruleset/check/check.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -144,10 +145,13 @@ func checkRun(opts *CheckOptions) error { var rules []shared.RulesetRule - endpoint := fmt.Sprintf("repos/%s/%s/rules/branches/%s", repoI.RepoOwner(), repoI.RepoName(), url.PathEscape(opts.Branch)) + endpoint, err := safeurl.JoinPath("repos", repoI.RepoOwner(), repoI.RepoName(), "rules", "branches", opts.Branch) + if err != nil { + return err + } - if err = client.REST(repoI.RepoHost(), "GET", endpoint, nil, &rules); err != nil { - return fmt.Errorf("GET %s failed: %w", endpoint, err) + if err = client.REST(repoI.RepoHost(), "GET", endpoint.String(), nil, &rules); err != nil { + return fmt.Errorf("GET %s failed: %w", endpoint.String(), err) } w := opts.IO.Out diff --git a/pkg/cmd/ruleset/view/http.go b/pkg/cmd/ruleset/view/http.go index d0b26f5301b..c182917b12b 100644 --- a/pkg/cmd/ruleset/view/http.go +++ b/pkg/cmd/ruleset/view/http.go @@ -1,29 +1,35 @@ package view import ( - "fmt" "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" ) func viewRepoRuleset(httpClient *http.Client, repo ghrepo.Interface, databaseId string) (*shared.RulesetREST, error) { - path := fmt.Sprintf("repos/%s/%s/rulesets/%s", repo.RepoOwner(), repo.RepoName(), databaseId) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "rulesets", databaseId) + if err != nil { + return nil, err + } return viewRuleset(httpClient, repo.RepoHost(), path) } func viewOrgRuleset(httpClient *http.Client, orgLogin string, databaseId string, host string) (*shared.RulesetREST, error) { - path := fmt.Sprintf("orgs/%s/rulesets/%s", orgLogin, databaseId) + path, err := safeurl.JoinPath("orgs", orgLogin, "rulesets", databaseId) + if err != nil { + return nil, err + } return viewRuleset(httpClient, host, path) } -func viewRuleset(httpClient *http.Client, hostname string, path string) (*shared.RulesetREST, error) { +func viewRuleset(httpClient *http.Client, hostname string, path safeurl.SafeURL) (*shared.RulesetREST, error) { apiClient := api.NewClientFromHTTP(httpClient) result := shared.RulesetREST{} - err := apiClient.REST(hostname, "GET", path, nil, &result) + err := apiClient.REST(hostname, "GET", path.String(), nil, &result) if err != nil { return nil, err } diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index d73e46a6827..296ca9f7ba1 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -142,14 +143,18 @@ func runCancel(opts *CancelOptions) error { } func cancelWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string, force bool) error { - var path string + var path *safeurl.MutableSafeURL + var err error if force { - path = fmt.Sprintf("repos/%s/actions/runs/%s/force-cancel", ghrepo.FullName(repo), runID) + path, err = safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", runID, "force-cancel") } else { - path = fmt.Sprintf("repos/%s/actions/runs/%s/cancel", ghrepo.FullName(repo), runID) + path, err = safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", runID, "cancel") + } + if err != nil { + return err } - err := client.REST(repo.RepoHost(), "POST", path, nil, nil) + err = client.REST(repo.RepoHost(), "POST", path.String(), nil, nil) if err != nil { return err } diff --git a/pkg/cmd/run/delete/delete.go b/pkg/cmd/run/delete/delete.go index 711e98c0296..6fc8e17b722 100644 --- a/pkg/cmd/run/delete/delete.go +++ b/pkg/cmd/run/delete/delete.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -138,8 +139,11 @@ func runDelete(opts *DeleteOptions) error { } func deleteWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string) error { - path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID) - err := client.REST(repo.RepoHost(), "DELETE", path, nil, nil) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", runID) + if err != nil { + return err + } + err = client.REST(repo.RepoHost(), "DELETE", path.String(), nil, nil) if err != nil { return err } diff --git a/pkg/cmd/run/download/download.go b/pkg/cmd/run/download/download.go index 6190325b958..347c17251df 100644 --- a/pkg/cmd/run/download/download.go +++ b/pkg/cmd/run/download/download.go @@ -7,6 +7,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/safepaths" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -28,7 +29,7 @@ type DownloadOptions struct { type platform interface { List(runID string) ([]shared.Artifact, error) - Download(url string, dir safepaths.Absolute) error + Download(url safeurl.SafeURL, dir safepaths.Absolute) error } type iprompter interface { @@ -187,7 +188,7 @@ func runDownload(opts *DownloadOptions) error { } } - err := opts.Platform.Download(a.DownloadURL, destDir) + err := opts.Platform.Download(safeurl.NewImmutableSafeURL(a.DownloadURL), destDir) if err != nil { return fmt.Errorf("error downloading %s: %w", a.Name, err) } diff --git a/pkg/cmd/run/download/download_test.go b/pkg/cmd/run/download/download_test.go index a90ae74b5a9..a001cd78111 100644 --- a/pkg/cmd/run/download/download_test.go +++ b/pkg/cmd/run/download/download_test.go @@ -13,6 +13,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safepaths" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -186,7 +187,7 @@ func (f *fakePlatform) List(runID string) ([]shared.Artifact, error) { return artifacts, nil } -func (f *fakePlatform) Download(url string, dir safepaths.Absolute) error { +func (f *fakePlatform) Download(url safeurl.SafeURL, dir safepaths.Absolute) error { if err := os.MkdirAll(dir.String(), 0755); err != nil { return err } @@ -197,7 +198,7 @@ func (f *fakePlatform) Download(url string, dir safepaths.Absolute) error { // Think fakePlatform { artifacts: ... } rather than fakePlatform.makeArtifactAvailable() for _, run := range f.runs { for _, testArtifact := range run.testArtifacts { - if testArtifact.artifact.DownloadURL == url { + if testArtifact.artifact.DownloadURL == url.String() { for _, file := range testArtifact.files { path := filepath.Join(dir.String(), file) return os.WriteFile(path, []byte{}, 0600) diff --git a/pkg/cmd/run/download/http.go b/pkg/cmd/run/download/http.go index 09293b056d6..a832f924b20 100644 --- a/pkg/cmd/run/download/http.go +++ b/pkg/cmd/run/download/http.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/safepaths" + "github.com/cli/cli/v2/internal/safeurl" ghzip "github.com/cli/cli/v2/internal/zip" "github.com/cli/cli/v2/pkg/cmd/run/shared" ) @@ -23,12 +24,12 @@ func (p *apiPlatform) List(runID string) ([]shared.Artifact, error) { return shared.ListArtifacts(p.client, p.repo, runID) } -func (p *apiPlatform) Download(url string, dir safepaths.Absolute) error { +func (p *apiPlatform) Download(url safeurl.SafeURL, dir safepaths.Absolute) error { return downloadArtifact(p.client, url, dir) } -func downloadArtifact(httpClient *http.Client, url string, destDir safepaths.Absolute) error { - req, err := http.NewRequest("GET", url, nil) +func downloadArtifact(httpClient *http.Client, url safeurl.SafeURL, destDir safepaths.Absolute) error { + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return err } diff --git a/pkg/cmd/run/download/http_test.go b/pkg/cmd/run/download/http_test.go index 75b52ae790e..5b68dff82d4 100644 --- a/pkg/cmd/run/download/http_test.go +++ b/pkg/cmd/run/download/http_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/safepaths" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -72,7 +73,7 @@ func Test_Download(t *testing.T) { api := &apiPlatform{ client: &http.Client{Transport: reg}, } - require.NoError(t, api.Download("https://api.github.com/repos/OWNER/REPO/actions/artifacts/12345/zip", destDir)) + require.NoError(t, api.Download(safeurl.NewImmutableSafeURL("https://api.github.com/repos/OWNER/REPO/actions/artifacts/12345/zip"), destDir)) var paths []string parentPrefix := tmpDir + string(filepath.Separator) diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go index 8777e0a8a18..8f8b79a2edf 100644 --- a/pkg/cmd/run/rerun/rerun.go +++ b/pkg/cmd/run/rerun/rerun.go @@ -7,10 +7,12 @@ import ( "fmt" "io" "net/http" + "strconv" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -196,9 +198,12 @@ func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFa return fmt.Errorf("failed to create rerun body: %w", err) } - path := fmt.Sprintf("repos/%s/actions/runs/%d/%s", ghrepo.FullName(repo), run.ID, runVerb) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", strconv.FormatInt(run.ID, 10), runVerb) + if err != nil { + return err + } - err = client.REST(repo.RepoHost(), "POST", path, body, nil) + err = client.REST(repo.RepoHost(), "POST", path.String(), body, nil) if err != nil { var httpError api.HTTPError if errors.As(err, &httpError) && httpError.StatusCode == 403 { @@ -215,9 +220,12 @@ func rerunJob(client *api.Client, repo ghrepo.Interface, job *shared.Job, debug return fmt.Errorf("failed to create rerun body: %w", err) } - path := fmt.Sprintf("repos/%s/actions/jobs/%d/rerun", ghrepo.FullName(repo), job.ID) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "jobs", strconv.FormatInt(job.ID, 10), "rerun") + if err != nil { + return err + } - err = client.REST(repo.RepoHost(), "POST", path, body, nil) + err = client.REST(repo.RepoHost(), "POST", path.String(), body, nil) if err != nil { var httpError api.HTTPError if errors.As(err, &httpError) && httpError.StatusCode == 403 { diff --git a/pkg/cmd/run/shared/artifacts.go b/pkg/cmd/run/shared/artifacts.go index e835958bec9..36d0b39e73c 100644 --- a/pkg/cmd/run/shared/artifacts.go +++ b/pkg/cmd/run/shared/artifacts.go @@ -2,13 +2,14 @@ package shared import ( "encoding/json" - "fmt" "net/http" "regexp" + "strconv" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" ) type Artifact struct { @@ -25,17 +26,24 @@ type artifactsPayload struct { func ListArtifacts(httpClient *http.Client, repo ghrepo.Interface, runID string) ([]Artifact, error) { var results []Artifact + restPrefix := ghinstance.RESTPrefix(repo.RepoHost()) perPage := 100 - path := fmt.Sprintf("repos/%s/%s/actions/artifacts?per_page=%d", repo.RepoOwner(), repo.RepoName(), perPage) + u, err := safeurl.JoinPathWithHostPrefix(restPrefix, "repos", repo.RepoOwner(), repo.RepoName(), "actions", "artifacts") + if err != nil { + return nil, err + } if runID != "" { - path = fmt.Sprintf("repos/%s/%s/actions/runs/%s/artifacts?per_page=%d", repo.RepoOwner(), repo.RepoName(), runID, perPage) + u, err = safeurl.JoinPathWithHostPrefix(restPrefix, "repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", runID, "artifacts") + if err != nil { + return nil, err + } } - - url := fmt.Sprintf("%s%s", ghinstance.RESTPrefix(repo.RepoHost()), path) + u.SetQuery("per_page", strconv.Itoa(perPage)) + var pageURL safeurl.SafeURL = u for { var payload artifactsPayload - nextURL, err := apiGet(httpClient, url, &payload) + nextURL, err := apiGet(httpClient, pageURL, &payload) if err != nil { return nil, err } @@ -44,14 +52,14 @@ func ListArtifacts(httpClient *http.Client, repo ghrepo.Interface, runID string) if nextURL == "" { break } - url = nextURL + pageURL = safeurl.NewImmutableSafeURL(nextURL) } return results, nil } -func apiGet(httpClient *http.Client, url string, data interface{}) (string, error) { - req, err := http.NewRequest("GET", url, nil) +func apiGet(httpClient *http.Client, url safeurl.SafeURL, data interface{}) (string, error) { + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return "", err } diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 8c191846a1f..6526292e24c 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -6,11 +6,13 @@ import ( "net/http" "net/url" "reflect" + "strconv" "strings" "time" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/iostreams" ) @@ -106,7 +108,7 @@ type Run struct { HeadSha string `json:"head_sha"` URL string `json:"html_url"` HeadRepository Repo `json:"head_repository"` - Jobs []Job `json:"-"` // populated by GetJobs + Jobs []Job `json:"-"` // Populated manually (separate from fetching the run) } func (r *Run) StartedTime() time.Time { @@ -280,9 +282,12 @@ var ErrMissingAnnotationsPermissions = errors.New("missing annotations permissio func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) { var result []*Annotation - path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", ghrepo.FullName(repo), job.ID) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "check-runs", strconv.FormatInt(job.ID, 10), "annotations") + if err != nil { + return nil, err + } - err := client.REST(repo.RepoHost(), "GET", path, nil, &result) + err = client.REST(repo.RepoHost(), "GET", path.String(), nil, &result) if err != nil { var httpError api.HTTPError if !errors.As(err, &httpError) { @@ -361,49 +366,56 @@ func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, opts *FilterOp } func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int) (*RunsPayload, error) { - path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo)) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs") + if err != nil { + return nil, err + } if opts != nil && opts.WorkflowID > 0 { - path = fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), opts.WorkflowID) + u, err = safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "workflows", strconv.FormatInt(opts.WorkflowID, 10), "runs") + if err != nil { + return nil, err + } } perPage := limit if limit > 100 { perPage = 100 } - path += fmt.Sprintf("?per_page=%d", perPage) - path += "&exclude_pull_requests=true" // significantly reduces payload size + u.SetQuery("per_page", strconv.Itoa(perPage)) + u.SetQuery("exclude_pull_requests", "true") // significantly reduces payload size if opts != nil { if opts.Branch != "" { - path += fmt.Sprintf("&branch=%s", url.QueryEscape(opts.Branch)) + u.SetQuery("branch", opts.Branch) } if opts.Actor != "" { - path += fmt.Sprintf("&actor=%s", url.QueryEscape(opts.Actor)) + u.SetQuery("actor", opts.Actor) } if opts.Status != "" { - path += fmt.Sprintf("&status=%s", url.QueryEscape(opts.Status)) + u.SetQuery("status", opts.Status) } if opts.Event != "" { - path += fmt.Sprintf("&event=%s", url.QueryEscape(opts.Event)) + u.SetQuery("event", opts.Event) } if opts.Created != "" { - path += fmt.Sprintf("&created=%s", url.QueryEscape(opts.Created)) + u.SetQuery("created", opts.Created) } if opts.Commit != "" { - path += fmt.Sprintf("&head_sha=%s", url.QueryEscape(opts.Commit)) + u.SetQuery("head_sha", opts.Commit) } } + var pageURL safeurl.SafeURL = u var result *RunsPayload pagination: - for path != "" { + for pageURL.String() != "" { var response RunsPayload - var err error - path, err = client.RESTWithNext(repo.RepoHost(), "GET", path, nil, &response) + next, err := client.RESTWithNext(repo.RepoHost(), "GET", pageURL.String(), nil, &response) if err != nil { return nil, err } + pageURL = safeurl.NewImmutableSafeURL(next) if result == nil { result = &response @@ -475,38 +487,49 @@ type JobsPayload struct { Jobs []Job } -func GetJobs(client *api.Client, repo ghrepo.Interface, run *Run, attempt uint64) ([]Job, error) { - if run.Jobs != nil { - return run.Jobs, nil - } - - query := url.Values{} - query.Set("per_page", "100") - jobsPath := fmt.Sprintf("%s?%s", run.JobsURL, query.Encode()) - +func GetJobs(client *api.Client, repo ghrepo.Interface, runID int64, jobsURL safeurl.SafeURL, attempt uint64) ([]Job, error) { + var jobsPath safeurl.SafeURL if attempt > 0 { - jobsPath = fmt.Sprintf("repos/%s/actions/runs/%d/attempts/%d/jobs?%s", ghrepo.FullName(repo), run.ID, attempt, query.Encode()) + p, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", strconv.FormatInt(runID, 10), "attempts", strconv.FormatUint(attempt, 10), "jobs") + if err != nil { + return nil, err + } + p.SetQuery("per_page", "100") + jobsPath = p + } else { + u, err := url.Parse(jobsURL.String()) + if err != nil { + return nil, err + } + query := url.Values{} + query.Set("per_page", "100") + u.RawQuery = query.Encode() + // Since u is derived from jobsURL, an already-trusted safeurl.SafeURL, the resulting URL is safe to declare as such. + jobsPath = safeurl.NewImmutableSafeURL(u.String()) } - for jobsPath != "" { + // A non-nil empty slice is returned so callers can tell that jobs were fetched and there are none (if len is zero). + jobs := []Job{} + for jobsPath.String() != "" { var resp JobsPayload - var err error - jobsPath, err = client.RESTWithNext(repo.RepoHost(), http.MethodGet, jobsPath, nil, &resp) + next, err := client.RESTWithNext(repo.RepoHost(), http.MethodGet, jobsPath.String(), nil, &resp) if err != nil { - run.Jobs = nil return nil, err } - - run.Jobs = append(run.Jobs, resp.Jobs...) + jobs = append(jobs, resp.Jobs...) + jobsPath = safeurl.NewImmutableSafeURL(next) } - return run.Jobs, nil + return jobs, nil } func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, error) { - path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "jobs", jobID) + if err != nil { + return nil, err + } var result Job - err := client.REST(repo.RepoHost(), "GET", path, nil, &result) + err = client.REST(repo.RepoHost(), "GET", path.String(), nil, &result) if err != nil { return nil, err } @@ -539,13 +562,20 @@ func SelectRun(p Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) { var result Run - path := fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", runID) + if err != nil { + return nil, err + } if attempt > 0 { - path = fmt.Sprintf("repos/%s/actions/runs/%s/attempts/%d?exclude_pull_requests=true", ghrepo.FullName(repo), runID, attempt) + u, err = safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", runID, "attempts", strconv.FormatUint(attempt, 10)) + if err != nil { + return nil, err + } } + u.SetQuery("exclude_pull_requests", "true") - err := client.REST(repo.RepoHost(), "GET", path, nil, &result) + err = client.REST(repo.RepoHost(), "GET", u.String(), nil, &result) if err != nil { return nil, err } diff --git a/pkg/cmd/run/view/logs.go b/pkg/cmd/run/view/logs.go index 8961381b3b0..ab1232837bb 100644 --- a/pkg/cmd/run/view/logs.go +++ b/pkg/cmd/run/view/logs.go @@ -9,12 +9,14 @@ import ( "regexp" "slices" "sort" + "strconv" "strings" "unicode/utf16" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" ) @@ -38,10 +40,12 @@ type apiLogFetcher struct { } func (f *apiLogFetcher) GetLog() (io.ReadCloser, error) { - logURL := fmt.Sprintf("%srepos/%s/actions/jobs/%d/logs", - ghinstance.RESTPrefix(f.repo.RepoHost()), ghrepo.FullName(f.repo), f.jobID) + logURL, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(f.repo.RepoHost()), "repos", f.repo.RepoOwner(), f.repo.RepoName(), "actions", "jobs", strconv.FormatInt(f.jobID, 10), "logs") + if err != nil { + return nil, err + } - req, err := http.NewRequest("GET", logURL, nil) + req, err := http.NewRequest("GET", logURL.String(), nil) if err != nil { return nil, err } diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 3e5199452e2..95cdc891291 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -18,6 +18,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -260,11 +261,12 @@ func runView(opts *ViewOptions) error { if shouldFetchJobs(opts) { opts.IO.StartProgressIndicator() - jobs, err = shared.GetJobs(client, repo, run, attempt) + jobs, err = shared.GetJobs(client, repo, run.ID, safeurl.NewImmutableSafeURL(run.JobsURL), attempt) opts.IO.StopProgressIndicator() if err != nil { return err } + run.Jobs = jobs } if opts.Prompt && len(jobs) > 1 { @@ -298,11 +300,12 @@ func runView(opts *ViewOptions) error { if selectedJob == nil && len(jobs) == 0 { opts.IO.StartProgressIndicator() - jobs, err = shared.GetJobs(client, repo, run, attempt) + jobs, err = shared.GetJobs(client, repo, run.ID, safeurl.NewImmutableSafeURL(run.JobsURL), attempt) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get jobs: %w", err) } + run.Jobs = jobs } else if selectedJob != nil { jobs = []shared.Job{*selectedJob} } @@ -467,8 +470,8 @@ func shouldFetchJobs(opts *ViewOptions) bool { return false } -func getLog(httpClient *http.Client, logURL string) (io.ReadCloser, error) { - req, err := http.NewRequest("GET", logURL, nil) +func getLog(httpClient *http.Client, logURL safeurl.SafeURL) (io.ReadCloser, error) { + req, err := http.NewRequest("GET", logURL.String(), nil) if err != nil { return nil, err } @@ -496,12 +499,16 @@ func getRunLog(cache RunLogCache, httpClient *http.Client, repo ghrepo.Interface if !isCached { // Run log does not exist in cache so retrieve and store it - logURL := fmt.Sprintf("%srepos/%s/actions/runs/%d/logs", - ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID) + logURL, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", strconv.FormatInt(run.ID, 10), "logs") + if err != nil { + return nil, err + } if attempt > 0 { - logURL = fmt.Sprintf("%srepos/%s/actions/runs/%d/attempts/%d/logs", - ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID, attempt) + logURL, err = safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", strconv.FormatInt(run.ID, 10), "attempts", strconv.FormatUint(attempt, 10), "logs") + if err != nil { + return nil, err + } } resp, err := getLog(httpClient, logURL) diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go index a73a91e1a03..53ad4bc3540 100644 --- a/pkg/cmd/run/watch/watch.go +++ b/pkg/cmd/run/watch/watch.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -221,10 +222,11 @@ func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo return nil, fmt.Errorf("failed to get run: %w", err) } - jobs, err := shared.GetJobs(client, repo, run, 0) + jobs, err := shared.GetJobs(client, repo, run.ID, safeurl.NewImmutableSafeURL(run.JobsURL), 0) if err != nil { return nil, fmt.Errorf("failed to get jobs: %w", err) } + run.Jobs = jobs var annotations []shared.Annotation var missingAnnotationsPermissions bool diff --git a/pkg/cmd/secret/delete/delete.go b/pkg/cmd/secret/delete/delete.go index b1a5b1d3930..2550b8bbe2a 100644 --- a/pkg/cmd/secret/delete/delete.go +++ b/pkg/cmd/secret/delete/delete.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -123,24 +124,27 @@ func removeRun(opts *DeleteOptions) error { return err } - var path string + var path *safeurl.MutableSafeURL var host string switch secretEntity { case shared.Organization: - path = fmt.Sprintf("orgs/%s/%s/secrets/%s", orgName, secretApp, opts.SecretName) + path, err = safeurl.JoinPath("orgs", orgName, string(secretApp), "secrets", opts.SecretName) host, _ = cfg.Authentication().DefaultHost() case shared.Environment: - path = fmt.Sprintf("repos/%s/environments/%s/secrets/%s", ghrepo.FullName(baseRepo), envName, opts.SecretName) + path, err = safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "environments", envName, "secrets", opts.SecretName) host = baseRepo.RepoHost() case shared.User: - path = fmt.Sprintf("user/codespaces/secrets/%s", opts.SecretName) + path, err = safeurl.JoinPath("user", "codespaces", "secrets", opts.SecretName) host, _ = cfg.Authentication().DefaultHost() case shared.Repository: - path = fmt.Sprintf("repos/%s/%s/secrets/%s", ghrepo.FullName(baseRepo), secretApp, opts.SecretName) + path, err = safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), string(secretApp), "secrets", opts.SecretName) host = baseRepo.RepoHost() } + if err != nil { + return err + } - err = client.REST(host, "DELETE", path, nil, nil) + err = client.REST(host, "DELETE", path.String(), nil, nil) if err != nil { return fmt.Errorf("failed to delete secret %s: %w", opts.SecretName, err) } diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index 66334ea9152..3f47bc748e1 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -13,6 +13,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -248,30 +249,50 @@ func fmtVisibility(s Secret) string { } func getOrgSecrets(client *http.Client, host, orgName string, showSelectedRepoInfo bool, app shared.App) ([]Secret, error) { - secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/%s/secrets", orgName, app)) + u, err := safeurl.JoinPath("orgs", orgName, string(app), "secrets") + if err != nil { + return nil, err + } + secrets, err := getSecrets(client, host, u) if err != nil { return nil, err } if showSelectedRepoInfo { - err = populateSelectedRepositoryInformation(client, host, secrets) - if err != nil { - return nil, err + for i := range secrets { + if secrets[i].SelectedReposURL == "" { + continue + } + count, err := selectedRepositoryCount(client, host, safeurl.NewImmutableSafeURL(secrets[i].SelectedReposURL)) + if err != nil { + return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secrets[i].Name, err) + } + secrets[i].NumSelectedRepos = count } } return secrets, nil } func getUserSecrets(client *http.Client, host string, showSelectedRepoInfo bool) ([]Secret, error) { - secrets, err := getSecrets(client, host, "user/codespaces/secrets") + u, err := safeurl.JoinPath("user", "codespaces", "secrets") + if err != nil { + return nil, err + } + secrets, err := getSecrets(client, host, u) if err != nil { return nil, err } if showSelectedRepoInfo { - err = populateSelectedRepositoryInformation(client, host, secrets) - if err != nil { - return nil, err + for i := range secrets { + if secrets[i].SelectedReposURL == "" { + continue + } + count, err := selectedRepositoryCount(client, host, safeurl.NewImmutableSafeURL(secrets[i].SelectedReposURL)) + if err != nil { + return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secrets[i].Name, err) + } + secrets[i].NumSelectedRepos = count } } @@ -279,45 +300,47 @@ func getUserSecrets(client *http.Client, host string, showSelectedRepoInfo bool) } func getEnvSecrets(client *http.Client, repo ghrepo.Interface, envName string) ([]Secret, error) { - path := fmt.Sprintf("repos/%s/environments/%s/secrets", ghrepo.FullName(repo), envName) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "environments", envName, "secrets") + if err != nil { + return nil, err + } return getSecrets(client, repo.RepoHost(), path) } func getRepoSecrets(client *http.Client, repo ghrepo.Interface, app shared.App) ([]Secret, error) { - return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/%s/secrets", ghrepo.FullName(repo), app)) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), string(app), "secrets") + if err != nil { + return nil, err + } + return getSecrets(client, repo.RepoHost(), u) } -func getSecrets(client *http.Client, host, path string) ([]Secret, error) { +func getSecrets(client *http.Client, host string, u *safeurl.MutableSafeURL) ([]Secret, error) { var results []Secret apiClient := api.NewClientFromHTTP(client) - path = fmt.Sprintf("%s?per_page=100", path) - for path != "" { + u.SetQuery("per_page", "100") + var pageURL safeurl.SafeURL = u + for pageURL.String() != "" { response := struct { Secrets []Secret }{} - var err error - path, err = apiClient.RESTWithNext(host, "GET", path, nil, &response) + next, err := apiClient.RESTWithNext(host, "GET", pageURL.String(), nil, &response) if err != nil { return nil, err } + pageURL = safeurl.NewImmutableSafeURL(next) results = append(results, response.Secrets...) } return results, nil } -func populateSelectedRepositoryInformation(client *http.Client, host string, secrets []Secret) error { +func selectedRepositoryCount(client *http.Client, host string, selectedReposURL safeurl.SafeURL) (int, error) { apiClient := api.NewClientFromHTTP(client) - for i, secret := range secrets { - if secret.SelectedReposURL == "" { - continue - } - response := struct { - TotalCount int `json:"total_count"` - }{} - if err := apiClient.REST(host, "GET", secret.SelectedReposURL, nil, &response); err != nil { - return fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err) - } - secrets[i].NumSelectedRepos = response.TotalCount + response := struct { + TotalCount int `json:"total_count"` + }{} + if err := apiClient.REST(host, "GET", selectedReposURL.String(), nil, &response); err != nil { + return 0, err } - return nil + return response.TotalCount, nil } diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index da7cb892356..7e6c88a0002 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -857,7 +858,9 @@ func Test_getSecrets_pagination(t *testing.T) { httpmock.StringResponse(`{"secrets":[{},{}]}`), ) client := &http.Client{Transport: reg} - secrets, err := getSecrets(client, "github.com", "path/to") + u, err := safeurl.JoinPath("path", "to") + require.NoError(t, err) + secrets, err := getSecrets(client, "github.com", u) assert.NoError(t, err) assert.Equal(t, 4, len(secrets)) } diff --git a/pkg/cmd/secret/set/http.go b/pkg/cmd/secret/set/http.go index 7d623be1637..43da048a65a 100644 --- a/pkg/cmd/secret/set/http.go +++ b/pkg/cmd/secret/set/http.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/secret/shared" ) @@ -30,9 +31,9 @@ type PubKey struct { Key string } -func getPubKey(client *api.Client, host, path string) (*PubKey, error) { +func getPubKey(client *api.Client, host string, path safeurl.SafeURL) (*PubKey, error) { pk := PubKey{} - err := client.REST(host, "GET", path, nil, &pk) + err := client.REST(host, "GET", path.String(), nil, &pk) if err != nil { return nil, err } @@ -40,35 +41,52 @@ func getPubKey(client *api.Client, host, path string) (*PubKey, error) { } func getOrgPublicKey(client *api.Client, host, orgName string, app shared.App) (*PubKey, error) { - return getPubKey(client, host, fmt.Sprintf("orgs/%s/%s/secrets/public-key", orgName, app)) + u, err := safeurl.JoinPath("orgs", orgName, string(app), "secrets", "public-key") + if err != nil { + return nil, err + } + return getPubKey(client, host, u) } func getUserPublicKey(client *api.Client, host string) (*PubKey, error) { - return getPubKey(client, host, "user/codespaces/secrets/public-key") + u, err := safeurl.JoinPath("user", "codespaces", "secrets", "public-key") + if err != nil { + return nil, err + } + return getPubKey(client, host, u) } func getRepoPubKey(client *api.Client, repo ghrepo.Interface, app shared.App) (*PubKey, error) { - return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/%s/secrets/public-key", - ghrepo.FullName(repo), app)) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), string(app), "secrets", "public-key") + if err != nil { + return nil, err + } + return getPubKey(client, repo.RepoHost(), u) } func getEnvPubKey(client *api.Client, repo ghrepo.Interface, envName string) (*PubKey, error) { - return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/environments/%s/secrets/public-key", - ghrepo.FullName(repo), envName)) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "environments", envName, "secrets", "public-key") + if err != nil { + return nil, err + } + return getPubKey(client, repo.RepoHost(), u) } -func putSecret(client *api.Client, host, path string, payload interface{}) error { +func putSecret(client *api.Client, host string, path safeurl.SafeURL, payload interface{}) error { payloadBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to serialize: %w", err) } requestBody := bytes.NewReader(payloadBytes) - return client.REST(host, "PUT", path, requestBody, nil) + return client.REST(host, "PUT", path.String(), requestBody, nil) } func putOrgSecret(client *api.Client, host string, pk *PubKey, orgName, visibility, secretName, eValue string, repositoryIDs []int64, app shared.App) error { - path := fmt.Sprintf("orgs/%s/%s/secrets/%s", orgName, app, secretName) + path, err := safeurl.JoinPath("orgs", orgName, string(app), "secrets", secretName) + if err != nil { + return err + } if app == shared.Dependabot { repos := make([]string, len(repositoryIDs)) @@ -102,7 +120,10 @@ func putUserSecret(client *api.Client, host string, pk *PubKey, key, eValue stri KeyID: pk.ID, Repositories: repositoryIDs, } - path := fmt.Sprintf("user/codespaces/secrets/%s", key) + path, err := safeurl.JoinPath("user", "codespaces", "secrets", key) + if err != nil { + return err + } return putSecret(client, host, path, payload) } @@ -111,7 +132,10 @@ func putEnvSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, envName EncryptedValue: eValue, KeyID: pk.ID, } - path := fmt.Sprintf("repos/%s/environments/%s/secrets/%s", ghrepo.FullName(repo), envName, secretName) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "environments", envName, "secrets", secretName) + if err != nil { + return err + } return putSecret(client, repo.RepoHost(), path, payload) } @@ -120,6 +144,9 @@ func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secret EncryptedValue: eValue, KeyID: pk.ID, } - path := fmt.Sprintf("repos/%s/%s/secrets/%s", ghrepo.FullName(repo), app, secretName) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), string(app), "secrets", secretName) + if err != nil { + return err + } return putSecret(client, repo.RepoHost(), path, payload) } diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 7a7d3f17a3f..b56b4eeb6cc 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/installer" @@ -1296,14 +1297,16 @@ func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([ // installs from the re-publisher. // Returns (repo to redirect to, whether upstream was detected, error). func checkUpstreamProvenance(opts *InstallOptions, client *api.Client, hostname string, skill discovery.Skill, commitSHA string) (ghrepo.Interface, bool, error) { - apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", - opts.repo.RepoOwner(), opts.repo.RepoName(), - skill.Path+"/SKILL.md", commitSHA) + u, err := safeurl.JoinPath("repos", opts.repo.RepoOwner(), opts.repo.RepoName(), "contents", skill.Path+"/SKILL.md") + if err != nil { + return nil, false, err + } + u.SetQuery("ref", commitSHA) var fileResp struct { Content string `json:"content"` Encoding string `json:"encoding"` } - if err := client.REST(hostname, "GET", apiPath, nil, &fileResp); err != nil { + if err := client.REST(hostname, "GET", u.String(), nil, &fileResp); err != nil { return nil, false, nil //nolint:nilerr // best-effort check; failing to fetch is not fatal } if fileResp.Encoding != "base64" { diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 070f7ac4230..9a78da48042 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -221,7 +221,7 @@ func stubResolveVersion(reg *httpmock.Registry, owner, repo, tag, sha string) { httpmock.StringResponse(fmt.Sprintf(`{"tag_name": %q}`, tag)), ) reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, tag)), + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/ref/tags%%2F%s", owner, repo, tag)), httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": %q, "type": "commit"}}`, sha)), ) } @@ -656,10 +656,10 @@ func TestInstallRun(t *testing.T) { isTTY: true, stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v2.0.0"), + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads%2Fv2.0.0"), httpmock.StatusStringResponse(404, "not found")) reg.Register( - httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v2.0.0"), + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags%2Fv2.0.0"), httpmock.StringResponse(`{"object": {"sha": "def456", "type": "commit"}}`), ) stubDiscoverTree(reg, "monalisa", "skills-repo", "def456", @@ -766,10 +766,10 @@ func TestInstallRun(t *testing.T) { isTTY: true, stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v1.2.0"), + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads%2Fv1.2.0"), httpmock.StatusStringResponse(404, "not found")) reg.Register( - httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.2.0"), + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags%2Fv1.2.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", @@ -2716,7 +2716,7 @@ var republishedContent = heredoc.Doc(` func stubContentsAPI(reg *httpmock.Registry, owner, repo, path, content string) { encoded := base64.StdEncoding.EncodeToString([]byte(content)) reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, path)), + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, url.PathEscape(path))), httpmock.StringResponse(fmt.Sprintf(`{"content": %q, "encoding": "base64"}`, encoded)), ) } diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 06d50154c91..500af05924e 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -209,7 +209,7 @@ func previewRun(opts *PreviewOptions) error { return err } - rendered := opts.renderFile("SKILL.md", content) + rendered := opts.renderFile("SKILL.md", content.String()) // Collect extra files (everything that isn't SKILL.md) var extraFiles []discovery.SkillFile @@ -304,10 +304,11 @@ func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill disco continue } fetched++ - totalBytes += len(fileContent) + sanitized := fileContent.String() + totalBytes += len(sanitized) fmt.Fprintf(out, "\n%s\n\n", cs.Bold("── "+f.Path+" ──")) - fmt.Fprint(out, fileContent) - if !strings.HasSuffix(fileContent, "\n") { + fmt.Fprint(out, sanitized) + if !strings.HasSuffix(sanitized, "\n") { fmt.Fprintln(out) } } @@ -358,7 +359,7 @@ func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill di fmt.Fprintf(opts.IO.ErrOut, "%s could not fetch %s: %v\n", cs.Red("!"), selectedFile.Path, fetchErr) continue } - content = renderSelectedFilePreview(opts, selectedFile.Path, fileContent) + content = renderSelectedFilePreview(opts, selectedFile.Path, fileContent.String()) if !strings.HasSuffix(content, "\n") { content += "\n" } diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 04fae62587e..1ae93026bd7 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -142,7 +142,7 @@ func TestPreviewRun(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -185,7 +185,7 @@ func TestPreviewRun(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -229,7 +229,7 @@ func TestPreviewRun(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -274,7 +274,7 @@ func TestPreviewRun(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -311,7 +311,7 @@ func TestPreviewRun(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -340,7 +340,7 @@ func TestPreviewRun(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -368,11 +368,11 @@ func TestPreviewRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { // ResolveRef with explicit version tries branch first, then tag, then commit reg.Register( - httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/heads/abc123def456"), + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/heads%2Fabc123def456"), httpmock.StatusStringResponse(404, "not found"), ) reg.Register( - httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/abc123def456"), + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags%2Fabc123def456"), httpmock.StatusStringResponse(404, "not found"), ) reg.Register( @@ -464,7 +464,7 @@ func TestPreviewRun_Interactive(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -539,7 +539,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -628,7 +628,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -777,7 +777,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -916,7 +916,7 @@ func TestPreviewRun_InteractiveTelemetryCapturesSelectedSkillName(t *testing.T) httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -1028,7 +1028,7 @@ func TestPreviewRun_TelemetryVisibility(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -1223,7 +1223,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -1271,7 +1271,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( @@ -1329,7 +1329,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/owner/repo/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), ) reg.Register( diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index a9fbd462f5e..9c5c0f5e2f5 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -20,6 +20,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/registry" @@ -443,9 +444,12 @@ func repoHasTopic(client *api.Client, host, owner, repo string) bool { if client == nil { return false } - apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "topics") + if err != nil { + return false + } var resp repoTopicsResponse - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { return false } for _, t := range resp.Names { @@ -461,9 +465,13 @@ func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { if client == nil { return nil } - apiPath := fmt.Sprintf("repos/%s/%s/tags?per_page=10", owner, repo) + u, err := safeurl.JoinPath("repos", owner, repo, "tags") + if err != nil { + return nil + } + u.SetQuery("per_page", "10") var tags []tagEntry - if err := client.REST(host, "GET", apiPath, nil, &tags); err != nil { + if err := client.REST(host, "GET", u.String(), nil, &tags); err != nil { return nil } return tags @@ -609,11 +617,14 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re return fmt.Errorf("failed to serialize release request: %w", err) } - releasePath := fmt.Sprintf("repos/%s/%s/releases", owner, repo) + releasePath, err := safeurl.JoinPath("repos", owner, repo, "releases") + if err != nil { + return err + } var releaseResp struct { HTMLURL string `json:"html_url"` } - if err := client.REST(host, "POST", releasePath, bytes.NewReader(releaseJSON), &releaseResp); err != nil { + if err := client.REST(host, "POST", releasePath.String(), bytes.NewReader(releaseJSON), &releaseResp); err != nil { return fmt.Errorf("failed to create release: %w", err) } @@ -683,7 +694,11 @@ func detectDefaultBranch(client *api.Client, host, owner, repo string) string { var result struct { DefaultBranch string `json:"default_branch"` } - if err := client.REST(host, "GET", fmt.Sprintf("repos/%s/%s", owner, repo), nil, &result); err != nil { + apiPath, err := safeurl.JoinPath("repos", owner, repo) + if err != nil { + return "" + } + if err := client.REST(host, "GET", apiPath.String(), nil, &result); err != nil { return "" } return result.DefaultBranch @@ -691,11 +706,14 @@ func detectDefaultBranch(client *api.Client, host, owner, repo string) string { // addAgentSkillsTopic adds the "agent-skills" topic to the repo, preserving existing topics. func addAgentSkillsTopic(client *api.Client, host, owner, repo string) error { - apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "topics") + if err != nil { + return err + } // Fetch existing topics var resp repoTopicsResponse - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { return fmt.Errorf("could not fetch existing topics: %w", err) } @@ -711,7 +729,7 @@ func addAgentSkillsTopic(client *api.Client, host, owner, repo string) error { if err != nil { return fmt.Errorf("could not serialize topics: %w", err) } - return client.REST(host, "PUT", apiPath, bytes.NewReader(topicsJSON), nil) + return client.REST(host, "PUT", apiPath.String(), bytes.NewReader(topicsJSON), nil) } // checkImmutableReleases checks if immutable releases are enabled for the repo. @@ -719,11 +737,14 @@ func checkImmutableReleases(client *api.Client, host, owner, repo string) bool { if client == nil { return false } - apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "immutable-releases") + if err != nil { + return false + } var resp struct { Enabled bool `json:"enabled"` } - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { return false } return resp.Enabled @@ -731,9 +752,12 @@ func checkImmutableReleases(client *api.Client, host, owner, repo string) bool { // enableImmutableReleases enables immutable releases for the repo. func enableImmutableReleases(client *api.Client, host, owner, repo string) error { - apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "immutable-releases") + if err != nil { + return err + } body := bytes.NewReader([]byte(`{"enabled":true}`)) - return client.REST(host, "PATCH", apiPath, body, nil) + return client.REST(host, "PATCH", apiPath.String(), body, nil) } // checkTagProtection checks whether tag protection rulesets are enabled. @@ -741,9 +765,12 @@ func checkTagProtection(client *api.Client, host, owner, repo string) []publishD if client == nil { return nil } - apiPath := fmt.Sprintf("repos/%s/%s/rulesets", owner, repo) + apiPath, err := safeurl.JoinPath("repos", owner, repo, "rulesets") + if err != nil { + return nil + } var rulesets []rulesetsResponse - if err := client.REST(host, "GET", apiPath, nil, &rulesets); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &rulesets); err != nil { return nil } @@ -764,9 +791,12 @@ func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDi if client == nil { return nil } - apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + apiPath, err := safeurl.JoinPath("repos", owner, repo) + if err != nil { + return nil + } var resp repoSecurityResponse - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { return nil } @@ -794,22 +824,26 @@ func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDi hasCode, hasManifests := detectCodeAndManifests(skillDirs) if hasCode { - alertsPath := fmt.Sprintf("repos/%s/%s/code-scanning/alerts?per_page=1&state=open", owner, repo) - if err := client.REST(host, "GET", alertsPath, nil, new([]interface{})); err != nil { - diagnostics = append(diagnostics, publishDiagnostic{ - severity: "info", - message: "skills include code files but code scanning does not appear to be configured (Settings > Code security > Code scanning)", - }) + if u, err := safeurl.JoinPath("repos", owner, repo, "code-scanning", "alerts"); err == nil { + u.SetQuery("per_page", "1") + u.SetQuery("state", "open") + if err := client.REST(host, "GET", u.String(), nil, new([]interface{})); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include code files but code scanning does not appear to be configured (Settings > Code security > Code scanning)", + }) + } } } if hasManifests { - dependabotPath := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", owner, repo) - if err := client.REST(host, "GET", dependabotPath, nil, nil); err != nil { - diagnostics = append(diagnostics, publishDiagnostic{ - severity: "info", - message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings > Code security > Dependabot)", - }) + if dependabotPath, err := safeurl.JoinPath("repos", owner, repo, "vulnerability-alerts"); err == nil { + if err := client.REST(host, "GET", dependabotPath.String(), nil, nil); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings > Code security > Dependabot)", + }) + } } } diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 5f510aae2d2..1a5353d59eb 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -5,10 +5,10 @@ import ( "fmt" "math" "net/http" - "net/url" "os" "os/exec" "sort" + "strconv" "strings" "sync" @@ -17,6 +17,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/registry" @@ -733,10 +734,15 @@ const rateLimitErrorMessage = "GitHub API rate limit exceeded. Please wait a min // executeSearch performs a single GitHub Code Search API call. func executeSearch(client *api.Client, host, query string, page, pageSize int) (*codeSearchResult, error) { - apiPath := fmt.Sprintf("search/code?q=%s&per_page=%d&page=%d", - url.QueryEscape(query), pageSize, page) + apiPath, err := safeurl.JoinPath("search", "code") + if err != nil { + return nil, err + } + apiPath.SetQuery("q", query) + apiPath.SetQuery("per_page", strconv.Itoa(pageSize)) + apiPath.SetQuery("page", strconv.Itoa(page)) var result codeSearchResult - err := client.REST(host, "GET", apiPath, nil, &result) + err = client.REST(host, "GET", apiPath.String(), nil, &result) if err != nil && isRateLimitError(err) { return nil, fmt.Errorf("%s", rateLimitErrorMessage) } @@ -855,7 +861,7 @@ func fetchDescriptions(client *api.Client, host string, skills []skillResult) ma if err != nil { return } - result, err := frontmatter.Parse(content) + result, err := frontmatter.Parse(content.Raw()) if err != nil { return } @@ -914,9 +920,12 @@ func fetchRepoStars(client *api.Client, host string, skills []skillResult) map[i sem <- struct{}{} defer func() { <-sem }() - apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + apiPath, err := safeurl.JoinPath("repos", owner, repo) + if err != nil { + return + } var info repoInfo - if err := client.REST(host, "GET", apiPath, nil, &info); err != nil { + if err := client.REST(host, "GET", apiPath.String(), nil, &info); err != nil { return } mu.Lock() diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 7a4d4ab18e7..cb6caac6306 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -345,7 +345,7 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "commit1", "type": "commit"}}`)) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit1"), @@ -532,7 +532,7 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), ) reg.Register( @@ -576,7 +576,7 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags%2Fv2.0.0"), httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), ) reg.Register( @@ -624,7 +624,7 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), ) reg.Register( - httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags%2Fv2.0.0"), httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), ) reg.Register( @@ -672,7 +672,7 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), @@ -735,7 +735,7 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), @@ -806,7 +806,7 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), @@ -865,7 +865,7 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), @@ -927,7 +927,7 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), @@ -1011,7 +1011,7 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv1.0.0"), httpmock.StringResponse(`{"object": {"sha": "commit123", "type": "commit"}}`)) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit123"), @@ -1081,7 +1081,7 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) reg.Register( - httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags%2Fv2.0.0"), httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) reg.Register( httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), @@ -1174,7 +1174,7 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) reg.Register( - httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags%2Fv2.0.0"), httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) reg.Register( httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), diff --git a/pkg/cmd/ssh-key/add/http.go b/pkg/cmd/ssh-key/add/http.go index 83aa77bdc86..1efe1d34197 100644 --- a/pkg/cmd/ssh-key/add/http.go +++ b/pkg/cmd/ssh-key/add/http.go @@ -10,12 +10,16 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" ) // Uploads the provided SSH key. Returns true if the key was uploaded, false if it was not. func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) { - url := ghinstance.RESTPrefix(hostname) + "user/keys" + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(hostname), "user", "keys") + if err != nil { + return false, err + } keyBytes, err := io.ReadAll(keyFile) if err != nil { @@ -46,7 +50,7 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t "key": fullUserKey, } - err = keyUpload(httpClient, url, payload) + err = keyUpload(httpClient, u, payload) if err != nil { return false, err @@ -57,7 +61,10 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t // Uploads the provided SSH Signing key. Returns true if the key was uploaded, false if it was not. func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) { - url := ghinstance.RESTPrefix(hostname) + "user/ssh_signing_keys" + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(hostname), "user", "ssh_signing_keys") + if err != nil { + return false, err + } keyBytes, err := io.ReadAll(keyFile) if err != nil { @@ -88,7 +95,7 @@ func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Re "key": fullUserKey, } - err = keyUpload(httpClient, url, payload) + err = keyUpload(httpClient, u, payload) if err != nil { return false, err @@ -97,13 +104,13 @@ func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Re return true, nil } -func keyUpload(httpClient *http.Client, url string, payload map[string]string) error { +func keyUpload(httpClient *http.Client, u safeurl.SafeURL, payload map[string]string) error { payloadBytes, err := json.Marshal(payload) if err != nil { return err } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) + req, err := http.NewRequest("POST", u.String(), bytes.NewBuffer(payloadBytes)) if err != nil { return err } diff --git a/pkg/cmd/ssh-key/delete/http.go b/pkg/cmd/ssh-key/delete/http.go index 906ae6bc906..a23502e6489 100644 --- a/pkg/cmd/ssh-key/delete/http.go +++ b/pkg/cmd/ssh-key/delete/http.go @@ -2,12 +2,12 @@ package delete import ( "encoding/json" - "fmt" "io" "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" ) type sshKey struct { @@ -15,8 +15,11 @@ type sshKey struct { } func deleteSSHKey(httpClient *http.Client, host string, keyID string) error { - url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) - req, err := http.NewRequest("DELETE", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "user", "keys", keyID) + if err != nil { + return err + } + req, err := http.NewRequest("DELETE", url.String(), nil) if err != nil { return err } @@ -35,8 +38,11 @@ func deleteSSHKey(httpClient *http.Client, host string, keyID string) error { } func getSSHKey(httpClient *http.Client, host string, keyID string) (*sshKey, error) { - url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) - req, err := http.NewRequest("GET", url, nil) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "user", "keys", keyID) + if err != nil { + return nil, err + } + req, err := http.NewRequest("GET", url.String(), nil) if err != nil { return nil, err } diff --git a/pkg/cmd/ssh-key/shared/user_keys.go b/pkg/cmd/ssh-key/shared/user_keys.go index 6a3d286ab62..4f1553afba8 100644 --- a/pkg/cmd/ssh-key/shared/user_keys.go +++ b/pkg/cmd/ssh-key/shared/user_keys.go @@ -2,13 +2,13 @@ package shared import ( "encoding/json" - "fmt" "io" "net/http" "time" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" ) const ( @@ -25,13 +25,19 @@ type sshKey struct { } func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) { - resource := "user/keys" + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "user", "keys") + if err != nil { + return nil, err + } if userHandle != "" { - resource = fmt.Sprintf("users/%s/keys", userHandle) + u, err = safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "users", userHandle, "keys") + if err != nil { + return nil, err + } } - url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) + u.SetQuery("per_page", "100") - keys, err := getUserKeys(httpClient, url) + keys, err := getUserKeys(httpClient, u) if err != nil { return nil, err @@ -45,13 +51,19 @@ func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error } func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) { - resource := "user/ssh_signing_keys" + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "user", "ssh_signing_keys") + if err != nil { + return nil, err + } if userHandle != "" { - resource = fmt.Sprintf("users/%s/ssh_signing_keys", userHandle) + u, err = safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(host), "users", userHandle, "ssh_signing_keys") + if err != nil { + return nil, err + } } - url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) + u.SetQuery("per_page", "100") - keys, err := getUserKeys(httpClient, url) + keys, err := getUserKeys(httpClient, u) if err != nil { return nil, err @@ -64,8 +76,8 @@ func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey return keys, nil } -func getUserKeys(httpClient *http.Client, url string) ([]sshKey, error) { - req, err := http.NewRequest("GET", url, nil) +func getUserKeys(httpClient *http.Client, u safeurl.SafeURL) ([]sshKey, error) { + req, err := http.NewRequest("GET", u.String(), nil) if err != nil { return nil, err } diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index c9acce8bd69..6dbd4199986 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -6,8 +6,8 @@ import ( "errors" "fmt" "net/http" - "net/url" "sort" + "strconv" "strings" "sync" "time" @@ -15,6 +15,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/charmbracelet/lipgloss" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmdutil" @@ -233,7 +234,7 @@ func (s *StatusGetter) CurrentUsername() (string, error) { return currentUsername, nil } -func (s *StatusGetter) ActualMention(commentURL string) (string, error) { +func (s *StatusGetter) ActualMention(commentURL safeurl.SafeURL) (string, error) { currentUsername, err := s.CurrentUsername() if err != nil { return "", err @@ -246,7 +247,7 @@ func (s *StatusGetter) ActualMention(commentURL string) (string, error) { resp := struct { Body string }{} - if err := c.REST(s.hostname(), "GET", commentURL, nil, &resp); err != nil { + if err := c.REST(s.hostname(), "GET", commentURL.String(), nil, &resp); err != nil { return "", err } @@ -264,10 +265,6 @@ func (s *StatusGetter) ActualMention(commentURL string) (string, error) { func (s *StatusGetter) LoadNotifications() error { perPage := 100 c := api.NewClientFromHTTP(s.Client) - query := url.Values{} - query.Add("per_page", fmt.Sprintf("%d", perPage)) - query.Add("participating", "true") - query.Add("all", "true") fetchWorkers := 10 ctx, abortFetching := context.WithCancel(context.Background()) @@ -286,7 +283,7 @@ func (s *StatusGetter) LoadNotifications() error { if !ok { return nil } - actual, err := s.ActualMention(n.Subject.LatestCommentURL) + actual, err := s.ActualMention(safeurl.NewImmutableSafeURL(n.Subject.LatestCommentURL)) if err != nil { var httpErr api.HTTPError @@ -336,10 +333,17 @@ func (s *StatusGetter) LoadNotifications() error { // do that. I'd switch to the GraphQL version, but to my knowledge that does // not work with PATs right now. nIndex := 0 - p := fmt.Sprintf("notifications?%s", query.Encode()) + u, err := safeurl.JoinPath("notifications") + if err != nil { + return err + } + u.SetQuery("per_page", strconv.Itoa(perPage)) + u.SetQuery("participating", "true") + u.SetQuery("all", "true") + var p safeurl.SafeURL = u for pages := 0; pages < 3; pages++ { var resp []Notification - next, err := c.RESTWithNext(s.hostname(), "GET", p, nil, &resp) + next, err := c.RESTWithNext(s.hostname(), "GET", p.String(), nil, &resp) if err != nil { var httpErr api.HTTPError if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 { @@ -365,11 +369,11 @@ func (s *StatusGetter) LoadNotifications() error { if next == "" || len(resp) < perPage { break } - p = next + p = safeurl.NewImmutableSafeURL(next) } close(toFetch) - err := wg.Wait() + err = wg.Wait() close(fetched) <-doneCh sort.Slice(s.Mentions, func(i, j int) bool { @@ -530,8 +534,6 @@ func (s *StatusGetter) LoadSearchResults() error { func (s *StatusGetter) LoadEvents() error { perPage := 100 c := api.NewClientFromHTTP(s.Client) - query := url.Values{} - query.Add("per_page", fmt.Sprintf("%d", perPage)) currentUsername, err := s.CurrentUsername() if err != nil { @@ -541,9 +543,14 @@ func (s *StatusGetter) LoadEvents() error { var events []Event var resp []Event pages := 0 - p := fmt.Sprintf("users/%s/received_events?%s", currentUsername, query.Encode()) + u, err := safeurl.JoinPath("users", currentUsername, "received_events") + if err != nil { + return err + } + u.SetQuery("per_page", strconv.Itoa(perPage)) + var p safeurl.SafeURL = u for pages < 2 { - next, err := c.RESTWithNext(s.hostname(), "GET", p, nil, &resp) + next, err := c.RESTWithNext(s.hostname(), "GET", p.String(), nil, &resp) if err != nil { var httpErr api.HTTPError if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 { @@ -556,7 +563,7 @@ func (s *StatusGetter) LoadEvents() error { } pages++ - p = next + p = safeurl.NewImmutableSafeURL(next) } s.RepoActivity = []StatusItem{} diff --git a/pkg/cmd/variable/delete/delete.go b/pkg/cmd/variable/delete/delete.go index d5132016751..d8c12d28ddf 100644 --- a/pkg/cmd/variable/delete/delete.go +++ b/pkg/cmd/variable/delete/delete.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/variable/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -96,21 +97,24 @@ func removeRun(opts *DeleteOptions) error { return err } - var path string + var path *safeurl.MutableSafeURL var host string switch variableEntity { case shared.Organization: - path = fmt.Sprintf("orgs/%s/actions/variables/%s", orgName, opts.VariableName) + path, err = safeurl.JoinPath("orgs", orgName, "actions", "variables", opts.VariableName) host, _ = cfg.Authentication().DefaultHost() case shared.Environment: - path = fmt.Sprintf("repos/%s/environments/%s/variables/%s", ghrepo.FullName(baseRepo), envName, opts.VariableName) + path, err = safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "environments", envName, "variables", opts.VariableName) host = baseRepo.RepoHost() case shared.Repository: - path = fmt.Sprintf("repos/%s/actions/variables/%s", ghrepo.FullName(baseRepo), opts.VariableName) + path, err = safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "actions", "variables", opts.VariableName) host = baseRepo.RepoHost() } + if err != nil { + return err + } - err = client.REST(host, "DELETE", path, nil, nil) + err = client.REST(host, "DELETE", path.String(), nil, nil) if err != nil { return fmt.Errorf("failed to delete variable %s: %w", opts.VariableName, err) } diff --git a/pkg/cmd/variable/get/get.go b/pkg/cmd/variable/get/get.go index e4def5a03b2..6247715e2f9 100644 --- a/pkg/cmd/variable/get/get.go +++ b/pkg/cmd/variable/get/get.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/variable/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -97,22 +98,25 @@ func getRun(opts *GetOptions) error { return err } - var path string + var path *safeurl.MutableSafeURL var host string switch variableEntity { case shared.Organization: - path = fmt.Sprintf("orgs/%s/actions/variables/%s", orgName, opts.VariableName) + path, err = safeurl.JoinPath("orgs", orgName, "actions", "variables", opts.VariableName) host, _ = cfg.Authentication().DefaultHost() case shared.Environment: - path = fmt.Sprintf("repos/%s/environments/%s/variables/%s", ghrepo.FullName(baseRepo), envName, opts.VariableName) + path, err = safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "environments", envName, "variables", opts.VariableName) host = baseRepo.RepoHost() case shared.Repository: - path = fmt.Sprintf("repos/%s/actions/variables/%s", ghrepo.FullName(baseRepo), opts.VariableName) + path, err = safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "actions", "variables", opts.VariableName) host = baseRepo.RepoHost() } + if err != nil { + return err + } var variable shared.Variable - if err = client.REST(host, "GET", path, nil, &variable); err != nil { + if err = client.REST(host, "GET", path.String(), nil, &variable); err != nil { var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return fmt.Errorf("variable %s was not found", opts.VariableName) @@ -122,8 +126,12 @@ func getRun(opts *GetOptions) error { } if opts.Exporter != nil { - if err := shared.PopulateSelectedRepositoryInformation(client, host, &variable); err != nil { - return err + if variable.SelectedReposURL != "" { + count, err := shared.SelectedRepositoryCount(client, host, safeurl.NewImmutableSafeURL(variable.SelectedReposURL)) + if err != nil { + return fmt.Errorf("failed determining selected repositories for %s: %w", variable.Name, err) + } + variable.NumSelectedRepos = count } return opts.Exporter.Write(opts.IO, &variable) } diff --git a/pkg/cmd/variable/list/list.go b/pkg/cmd/variable/list/list.go index 764c0af4d13..7624da22577 100644 --- a/pkg/cmd/variable/list/list.go +++ b/pkg/cmd/variable/list/list.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/variable/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -193,42 +194,60 @@ func fmtVisibility(s shared.Variable) string { } func getRepoVariables(client *http.Client, repo ghrepo.Interface) ([]shared.Variable, error) { - return getVariables(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/variables", ghrepo.FullName(repo))) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "variables") + if err != nil { + return nil, err + } + return getVariables(client, repo.RepoHost(), u) } func getEnvVariables(client *http.Client, repo ghrepo.Interface, envName string) ([]shared.Variable, error) { - path := fmt.Sprintf("repos/%s/environments/%s/variables", ghrepo.FullName(repo), envName) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "environments", envName, "variables") + if err != nil { + return nil, err + } return getVariables(client, repo.RepoHost(), path) } func getOrgVariables(client *http.Client, host, orgName string, showSelectedRepoInfo bool) ([]shared.Variable, error) { - variables, err := getVariables(client, host, fmt.Sprintf("orgs/%s/actions/variables", orgName)) + u, err := safeurl.JoinPath("orgs", orgName, "actions", "variables") + if err != nil { + return nil, err + } + variables, err := getVariables(client, host, u) if err != nil { return nil, err } apiClient := api.NewClientFromHTTP(client) if showSelectedRepoInfo { - err = shared.PopulateMultipleSelectedRepositoryInformation(apiClient, host, variables) - if err != nil { - return nil, err + for i := range variables { + if variables[i].SelectedReposURL == "" { + continue + } + count, err := shared.SelectedRepositoryCount(apiClient, host, safeurl.NewImmutableSafeURL(variables[i].SelectedReposURL)) + if err != nil { + return nil, fmt.Errorf("failed determining selected repositories for %s: %w", variables[i].Name, err) + } + variables[i].NumSelectedRepos = count } } return variables, nil } -func getVariables(client *http.Client, host, path string) ([]shared.Variable, error) { +func getVariables(client *http.Client, host string, u *safeurl.MutableSafeURL) ([]shared.Variable, error) { var results []shared.Variable apiClient := api.NewClientFromHTTP(client) - path = fmt.Sprintf("%s?per_page=100", path) - for path != "" { + u.SetQuery("per_page", "100") + var pageURL safeurl.SafeURL = u + for pageURL.String() != "" { response := struct { Variables []shared.Variable }{} - var err error - path, err = apiClient.RESTWithNext(host, "GET", path, nil, &response) + next, err := apiClient.RESTWithNext(host, "GET", pageURL.String(), nil, &response) if err != nil { return nil, err } + pageURL = safeurl.NewImmutableSafeURL(next) results = append(results, response.Variables...) } return results, nil diff --git a/pkg/cmd/variable/list/list_test.go b/pkg/cmd/variable/list/list_test.go index 4933d3957e4..46c68b1f53e 100644 --- a/pkg/cmd/variable/list/list_test.go +++ b/pkg/cmd/variable/list/list_test.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/variable/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -436,7 +437,9 @@ func Test_getVariables_pagination(t *testing.T) { httpmock.StringResponse(`{"variables":[{},{}]}`), ) client := &http.Client{Transport: reg} - variables, err := getVariables(client, "github.com", "path/to") + u, err := safeurl.JoinPath("path", "to") + require.NoError(t, err) + variables, err := getVariables(client, "github.com", u) assert.NoError(t, err) assert.Equal(t, 4, len(variables)) } diff --git a/pkg/cmd/variable/set/http.go b/pkg/cmd/variable/set/http.go index e3acb5e0a7f..2a1f581c676 100644 --- a/pkg/cmd/variable/set/http.go +++ b/pkg/cmd/variable/set/http.go @@ -5,9 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/variable/shared" ) @@ -82,13 +84,13 @@ func setVariable(client *api.Client, host string, opts setOptions) setResult { return result } -func postVariable(client *api.Client, host, path string, payload interface{}) error { +func postVariable(client *api.Client, host string, path safeurl.SafeURL, payload interface{}) error { payloadBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to serialize: %w", err) } requestBody := bytes.NewReader(payloadBytes) - return client.REST(host, "POST", path, requestBody, nil) + return client.REST(host, "POST", path.String(), requestBody, nil) } func postOrgVariable(client *api.Client, host, orgName, visibility, variableName, value string, repositoryIDs []int64) error { @@ -98,7 +100,10 @@ func postOrgVariable(client *api.Client, host, orgName, visibility, variableName Visibility: visibility, Repositories: repositoryIDs, } - path := fmt.Sprintf(`orgs/%s/actions/variables`, orgName) + path, err := safeurl.JoinPath("orgs", orgName, "actions", "variables") + if err != nil { + return err + } return postVariable(client, host, path, payload) } @@ -107,7 +112,10 @@ func postEnvVariable(client *api.Client, host string, repoID int64, envName, var Name: variableName, Value: value, } - path := fmt.Sprintf(`repositories/%d/environments/%s/variables`, repoID, envName) + path, err := safeurl.JoinPath("repositories", strconv.FormatInt(repoID, 10), "environments", envName, "variables") + if err != nil { + return err + } return postVariable(client, host, path, payload) } @@ -116,17 +124,20 @@ func postRepoVariable(client *api.Client, repo ghrepo.Interface, variableName, v Name: variableName, Value: value, } - path := fmt.Sprintf(`repos/%s/actions/variables`, ghrepo.FullName(repo)) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "variables") + if err != nil { + return err + } return postVariable(client, repo.RepoHost(), path, payload) } -func patchVariable(client *api.Client, host, path string, payload interface{}) error { +func patchVariable(client *api.Client, host string, path safeurl.SafeURL, payload interface{}) error { payloadBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to serialize: %w", err) } requestBody := bytes.NewReader(payloadBytes) - return client.REST(host, "PATCH", path, requestBody, nil) + return client.REST(host, "PATCH", path.String(), requestBody, nil) } func patchOrgVariable(client *api.Client, host, orgName, visibility, variableName, value string, repositoryIDs []int64) error { @@ -135,7 +146,10 @@ func patchOrgVariable(client *api.Client, host, orgName, visibility, variableNam Visibility: visibility, Repositories: repositoryIDs, } - path := fmt.Sprintf(`orgs/%s/actions/variables/%s`, orgName, variableName) + path, err := safeurl.JoinPath("orgs", orgName, "actions", "variables", variableName) + if err != nil { + return err + } return patchVariable(client, host, path, payload) } @@ -143,7 +157,10 @@ func patchEnvVariable(client *api.Client, host string, repoID int64, envName, va payload := setPayload{ Value: value, } - path := fmt.Sprintf(`repositories/%d/environments/%s/variables/%s`, repoID, envName, variableName) + path, err := safeurl.JoinPath("repositories", strconv.FormatInt(repoID, 10), "environments", envName, "variables", variableName) + if err != nil { + return err + } return patchVariable(client, host, path, payload) } @@ -151,6 +168,9 @@ func patchRepoVariable(client *api.Client, repo ghrepo.Interface, variableName, payload := setPayload{ Value: value, } - path := fmt.Sprintf(`repos/%s/actions/variables/%s`, ghrepo.FullName(repo), variableName) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "variables", variableName) + if err != nil { + return err + } return patchVariable(client, repo.RepoHost(), path, payload) } diff --git a/pkg/cmd/variable/shared/shared.go b/pkg/cmd/variable/shared/shared.go index c681242bc3f..de449c9864f 100644 --- a/pkg/cmd/variable/shared/shared.go +++ b/pkg/cmd/variable/shared/shared.go @@ -2,10 +2,10 @@ package shared import ( "errors" - "fmt" "time" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" ) @@ -66,27 +66,14 @@ func GetVariableEntity(orgName, envName string) (VariableEntity, error) { return Repository, nil } -func PopulateMultipleSelectedRepositoryInformation(apiClient *api.Client, host string, variables []Variable) error { - for i, variable := range variables { - if err := PopulateSelectedRepositoryInformation(apiClient, host, &variable); err != nil { - return err - } - variables[i] = variable - } - return nil -} - -func PopulateSelectedRepositoryInformation(apiClient *api.Client, host string, variable *Variable) error { - if variable.SelectedReposURL == "" { - return nil - } - +// SelectedRepositoryCount returns how many repositories the variable is visible to, fetched from the +// given entrusted URL. Callers own reading the URL off the variable and writing the result back. +func SelectedRepositoryCount(apiClient *api.Client, host string, selectedReposURL safeurl.SafeURL) (int, error) { response := struct { TotalCount int `json:"total_count"` }{} - if err := apiClient.REST(host, "GET", variable.SelectedReposURL, nil, &response); err != nil { - return fmt.Errorf("failed determining selected repositories for %s: %w", variable.Name, err) + if err := apiClient.REST(host, "GET", selectedReposURL.String(), nil, &response); err != nil { + return 0, err } - variable.NumSelectedRepos = response.TotalCount - return nil + return response.TotalCount, nil } diff --git a/pkg/cmd/workflow/disable/disable.go b/pkg/cmd/workflow/disable/disable.go index 8b2fb62d307..53882a17e17 100644 --- a/pkg/cmd/workflow/disable/disable.go +++ b/pkg/cmd/workflow/disable/disable.go @@ -4,9 +4,11 @@ import ( "errors" "fmt" "net/http" + "strconv" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -84,8 +86,11 @@ func runDisable(opts *DisableOptions) error { return err } - path := fmt.Sprintf("repos/%s/actions/workflows/%d/disable", ghrepo.FullName(repo), workflow.ID) - err = client.REST(repo.RepoHost(), "PUT", path, nil, nil) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "workflows", strconv.FormatInt(workflow.ID, 10), "disable") + if err != nil { + return err + } + err = client.REST(repo.RepoHost(), "PUT", path.String(), nil, nil) if err != nil { return fmt.Errorf("failed to disable workflow: %w", err) } diff --git a/pkg/cmd/workflow/enable/enable.go b/pkg/cmd/workflow/enable/enable.go index 93e8ac00719..1fc6eb755df 100644 --- a/pkg/cmd/workflow/enable/enable.go +++ b/pkg/cmd/workflow/enable/enable.go @@ -4,9 +4,11 @@ import ( "errors" "fmt" "net/http" + "strconv" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -84,8 +86,11 @@ func runEnable(opts *EnableOptions) error { return err } - path := fmt.Sprintf("repos/%s/actions/workflows/%d/enable", ghrepo.FullName(repo), workflow.ID) - err = client.REST(repo.RepoHost(), "PUT", path, nil, nil) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "workflows", strconv.FormatInt(workflow.ID, 10), "enable") + if err != nil { + return err + } + err = client.REST(repo.RepoHost(), "PUT", path.String(), nil, nil) if err != nil { return fmt.Errorf("failed to enable workflow: %w", err) } diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 9042b9249db..350c59bcd2c 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -7,9 +7,9 @@ import ( "fmt" "io" "net/http" - "net/url" "reflect" "sort" + "strconv" "strings" "time" @@ -17,6 +17,7 @@ import ( "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -319,7 +320,10 @@ func runRun(opts *RunOptions) error { return err } - path := fmt.Sprintf("repos/%s/%s/actions/workflows/%d/dispatches", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), workflow.ID) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "workflows", strconv.FormatInt(workflow.ID, 10), "dispatches") + if err != nil { + return err + } requestBody := map[string]interface{}{ "ref": ref, @@ -358,7 +362,7 @@ func runRun(opts *RunOptions) error { // // As a related note, the new REST API version (which will come with breaking // changes) will probably default to return 200 + run details. - err = client.REST(repo.RepoHost(), "POST", path, body, &response) + err = client.REST(repo.RepoHost(), "POST", path.String(), body, &response) if err != nil { return fmt.Errorf("could not create workflow dispatch event: %w", err) } diff --git a/pkg/cmd/workflow/run/run_test.go b/pkg/cmd/workflow/run/run_test.go index a4e44e5dabd..44a3b27853a 100644 --- a/pkg/cmd/workflow/run/run_test.go +++ b/pkg/cmd/workflow/run/run_test.go @@ -764,7 +764,7 @@ jobs: }, })) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/minimal.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fminimal.yml"), httpmock.JSONResponse(struct{ Content string }{ Content: encodedNoInputsYAMLContent, })) @@ -808,7 +808,7 @@ jobs: }, })) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/minimal.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fminimal.yml"), httpmock.JSONResponse(struct{ Content string }{ Content: encodedNoInputsYAMLContent, })) @@ -861,7 +861,7 @@ jobs: }, })) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fworkflow.yml"), httpmock.JSONResponse(struct{ Content string }{ Content: encodedYAMLContent, })) @@ -914,7 +914,7 @@ jobs: }, })) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fworkflow.yml"), httpmock.JSONResponse(struct{ Content string }{ Content: encodedYAMLContent, })) @@ -976,7 +976,7 @@ jobs: }, })) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fworkflow.yml"), httpmock.JSONResponse(struct{ Content string }{ Content: encodedYAMLContentChoiceIp, })) @@ -1030,7 +1030,7 @@ jobs: }, })) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fworkflow.yml"), httpmock.JSONResponse(struct{ Content string }{ Content: encodedYAMLContentChoiceIp, })) @@ -1091,7 +1091,7 @@ jobs: }, })) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fworkflow.yml"), httpmock.JSONResponse(struct{ Content string }{ Content: encodedYAMLContentMissingChoiceIp, })) diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go index 04b5fa199aa..2cb6b91ff94 100644 --- a/pkg/cmd/workflow/shared/shared.go +++ b/pkg/cmd/workflow/shared/shared.go @@ -6,13 +6,13 @@ import ( "errors" "fmt" "io" - "net/url" "path" "strconv" "strings" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/go-gh/v2/pkg/asciisanitizer" @@ -69,9 +69,14 @@ func GetWorkflows(client *api.Client, repo ghrepo.Interface, limit int) ([]Workf } var result WorkflowsPayload - path := fmt.Sprintf("repos/%s/actions/workflows?per_page=%d&page=%d", ghrepo.FullName(repo), perPage, page) + u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "workflows") + if err != nil { + return nil, err + } + u.SetQuery("per_page", strconv.Itoa(perPage)) + u.SetQuery("page", strconv.Itoa(page)) - err := client.REST(repo.RepoHost(), "GET", path, nil, &result) + err = client.REST(repo.RepoHost(), "GET", u.String(), nil, &result) if err != nil { return nil, err } @@ -159,8 +164,11 @@ func isWorkflowFile(f string) bool { func getWorkflowByID(client *api.Client, repo ghrepo.Interface, ID string) (*Workflow, error) { var workflow Workflow - path := fmt.Sprintf("repos/%s/actions/workflows/%s", ghrepo.FullName(repo), url.PathEscape(ID)) - if err := client.REST(repo.RepoHost(), "GET", path, nil, &workflow); err != nil { + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "workflows", ID) + if err != nil { + return nil, err + } + if err := client.REST(repo.RepoHost(), "GET", path.String(), nil, &workflow); err != nil { return nil, err } @@ -233,10 +241,12 @@ func ResolveWorkflow(p iprompter, io *iostreams.IOStreams, client *api.Client, r } func GetWorkflowContent(client *api.Client, repo ghrepo.Interface, workflow Workflow, ref string) ([]byte, error) { - path := fmt.Sprintf("repos/%s/contents/%s", ghrepo.FullName(repo), workflow.Path) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "contents", workflow.Path) + if err != nil { + return nil, err + } if ref != "" { - q := fmt.Sprintf("?ref=%s", url.QueryEscape(ref)) - path = path + q + path.SetQuery("ref", ref) } type Result struct { @@ -244,7 +254,7 @@ func GetWorkflowContent(client *api.Client, repo ghrepo.Interface, workflow Work } var result Result - err := client.REST(repo.RepoHost(), "GET", path, nil, &result) + err = client.REST(repo.RepoHost(), "GET", path.String(), nil, &result) if err != nil { return nil, err } diff --git a/pkg/cmd/workflow/view/view_test.go b/pkg/cmd/workflow/view/view_test.go index e5df52478be..db36797666b 100644 --- a/pkg/cmd/workflow/view/view_test.go +++ b/pkg/cmd/workflow/view/view_test.go @@ -289,7 +289,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(aWorkflow), ) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/flow.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fflow.yml"), httpmock.StringResponse(aWorkflowContent), ) }, @@ -308,7 +308,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(aWorkflow), ) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/flow.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fflow.yml"), httpmock.StringResponse(aWorkflowContent), ) }, @@ -327,7 +327,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(aWorkflow), ) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/flow.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fflow.yml"), httpmock.StatusStringResponse(404, "not Found"), ) }, @@ -348,7 +348,7 @@ func TestViewRun(t *testing.T) { httpmock.JSONResponse(aWorkflow), ) reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/flow.yml"), + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github%2Fworkflows%2Fflow.yml"), httpmock.StringResponse(aWorkflowContent), ) }, diff --git a/pkg/iostreams/content.go b/pkg/iostreams/content.go new file mode 100644 index 00000000000..b12389ad3d3 --- /dev/null +++ b/pkg/iostreams/content.go @@ -0,0 +1,92 @@ +package iostreams + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +// contentSniffLen is how many leading bytes are inspected to classify content as +// binary or textual, matching the sample size used by [http.DetectContentType]. +const contentSniffLen = 512 + +// ContainsEscapeSequence reports whether b contains an ANSI escape byte (0x1B), +// which can manipulate a terminal when printed. +func ContainsEscapeSequence(b []byte) bool { + return bytes.IndexByte(b, 0x1B) >= 0 +} + +// BinaryContentType reports whether content appears to be binary and, if so, +// returns its detected MIME type. Textual content returns ("", false). +func BinaryContentType(content []byte) (string, bool) { + if len(content) == 0 { + return "", false + } + ct := http.DetectContentType(content) + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = strings.TrimSpace(ct[:i]) + } + if strings.HasPrefix(ct, "text/") { + return "", false + } + return ct, true +} + +// BinaryTerminalError reports that binary content was about to be written to a +// terminal, where it is unreadable and may carry control bytes. +type BinaryTerminalError struct { + MIME string +} + +func (e BinaryTerminalError) Error() string { + return fmt.Sprintf("refusing to output binary content (%s) to the terminal", e.MIME) +} + +// ErrEscapeSequence reports that textual content carried terminal escape +// sequences and was refused. +var ErrEscapeSequence = errors.New("content contains terminal escape sequences") + +// CopyGuardedContent writes external content from r to w under the safety model +// used by byte-moving commands: binary content is refused when w targets a +// terminal and streamed verbatim otherwise, while textual content is refused when +// it carries terminal escape sequences. Binary content streams without buffering; +// only textual content is buffered, so its escapes are caught before any byte is +// written. On refusal the output stream is left untouched; otherwise it receives +// the full content. isTTY reports whether w targets the user's terminal. +// +// It returns [BinaryTerminalError] or [ErrEscapeSequence] so callers can add +// command-specific guidance. Callers that must stream verbatim (an explicit +// opt-out, or output bound for a file) should copy directly instead. +func CopyGuardedContent(w io.Writer, r io.Reader, isTTY bool) error { + head := make([]byte, contentSniffLen) + n, err := io.ReadFull(r, head) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return err + } + head = head[:n] + + if mime, ok := BinaryContentType(head); ok { + if isTTY { + return BinaryTerminalError{MIME: mime} + } + if _, err := w.Write(head); err != nil { + return err + } + _, err := io.Copy(w, r) + return err + } + + rest, err := io.ReadAll(r) + if err != nil { + return err + } + content := append(head, rest...) + if ContainsEscapeSequence(content) { + return ErrEscapeSequence + } + _, err = w.Write(content) + return err +} diff --git a/pkg/iostreams/content_test.go b/pkg/iostreams/content_test.go new file mode 100644 index 00000000000..7343d73b3df --- /dev/null +++ b/pkg/iostreams/content_test.go @@ -0,0 +1,152 @@ +package iostreams + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBinaryContentType(t *testing.T) { + tests := []struct { + name string + content []byte + wantMIME string + wantBinary bool + }{ + { + name: "empty content is not binary", + content: []byte{}, + wantBinary: false, + }, + { + name: "plain text is not binary", + content: []byte("hello world\n"), + wantBinary: false, + }, + { + name: "png is binary", + content: append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...), + wantMIME: "image/png", + wantBinary: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mime, ok := BinaryContentType(tt.content) + assert.Equal(t, tt.wantBinary, ok) + assert.Equal(t, tt.wantMIME, mime) + }) + } +} + +func TestContainsEscapeSequence(t *testing.T) { + assert.False(t, ContainsEscapeSequence([]byte("plain text"))) + assert.True(t, ContainsEscapeSequence([]byte("danger\x1b[31m"))) +} + +func TestCopyGuardedContent(t *testing.T) { + png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...) + readErr := errors.New("boom") + + tests := []struct { + name string + content []byte + // reader overrides content for cases a byte slice cannot express, such + // as a mid-stream read failure. + reader io.Reader + isTTY bool + wantOut []byte + // wantErrIs matches a sentinel error with errors.Is; wantErrAs matches a + // typed error (e.g. BinaryTerminalError, which carries a MIME field) with + // errors.As, so its value must be a pointer to that error type. + wantErrIs error + wantErrAs any + }{ + { + name: "clean text is written", + content: []byte("hello world\n"), + isTTY: true, + wantOut: []byte("hello world\n"), + }, + { + name: "text with escape is refused", + content: []byte("danger\x1b[31mtext"), + isTTY: true, + wantErrIs: ErrEscapeSequence, + }, + { + name: "text with escape is refused when piped", + content: []byte("danger\x1b[31mtext"), + isTTY: false, + wantErrIs: ErrEscapeSequence, + }, + { + name: "binary to terminal is refused", + content: png, + isTTY: true, + wantErrAs: &BinaryTerminalError{}, + }, + { + name: "binary when piped is written raw", + content: png, + isTTY: false, + wantOut: png, + }, + { + name: "empty content writes nothing", + content: []byte{}, + isTTY: true, + wantOut: nil, + }, + { + // Content past the sniff window is still inspected, so an escape + // hiding beyond the first chunk is caught. + name: "text with escape past the sniff window is refused", + content: append(bytes.Repeat([]byte("a"), contentSniffLen*2), []byte("\x1b[31m")...), + isTTY: false, + wantErrIs: ErrEscapeSequence, + }, + { + name: "read failure unrelated to EOF is surfaced", + reader: io.MultiReader(strings.NewReader("hi"), errReader{readErr}), + isTTY: false, + wantErrIs: readErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := tt.reader + if r == nil { + r = bytes.NewReader(tt.content) + } + + var buf bytes.Buffer + err := CopyGuardedContent(&buf, r, tt.isTTY) + + if tt.wantErrAs != nil { + require.ErrorAs(t, err, tt.wantErrAs) + assert.Empty(t, buf.Bytes()) + return + } + if tt.wantErrIs != nil { + require.ErrorIs(t, err, tt.wantErrIs) + assert.Empty(t, buf.Bytes()) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantOut, buf.Bytes()) + }) + } +} + +type errReader struct{ err error } + +func (e errReader) Read([]byte) (int, error) { return 0, e.err } diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 8eeb03725d5..429cadda891 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -13,11 +13,13 @@ import ( "time" "github.com/briandowns/spinner" + "github.com/cli/go-gh/v2/pkg/asciisanitizer" ghTerm "github.com/cli/go-gh/v2/pkg/term" "github.com/cli/safeexec" "github.com/google/shlex" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" + "golang.org/x/text/transform" ) const DefaultWidth = 80 @@ -53,6 +55,15 @@ type IOStreams struct { Out fileWriter ErrOut fileWriter + // ContentOut is the writer for external content (HTTP response bodies, + // gist files, etc.) where the application is not the author of the bytes. + // By default it sanitizes ANSI escape sequences before they reach the + // underlying stdout. SetContentSanitization toggles the sanitization at + // the command layer (e.g. via an --allow-escape-sequences flag). + ContentOut io.Writer + + sanitizeContent bool + terminalTheme string progressIndicatorEnabled bool @@ -241,6 +252,7 @@ func (s *IOStreams) StartPager() error { fd: s.Out.Fd(), WriteCloser: &pagerWriter{pagedOut}, } + s.ContentOut = newContentWriter(s.Out, s.sanitizeContent) err = pagerCmd.Start() if err != nil { return err @@ -475,6 +487,26 @@ func (s *IOStreams) ExperimentalPrompterEnabled() bool { return s.experimentalPrompterEnabled } +// SetContentSanitization toggles ANSI escape sanitization on ContentOut. +// Commands should call this with false when an explicit opt-out flag (e.g. +// --allow-escape-sequences) is set, so subsequent writes of external content +// pass through unmodified. +func (s *IOStreams) SetContentSanitization(enabled bool) { + s.sanitizeContent = enabled + s.ContentOut = newContentWriter(s.Out, enabled) +} + +// newContentWriter returns the writer to wire up as ContentOut. When +// sanitize is true it inserts an asciisanitizer in front of the underlying +// writer; otherwise it returns the underlying writer directly so writes +// reach stdout unchanged. +func newContentWriter(out io.Writer, sanitize bool) io.Writer { + if !sanitize { + return out + } + return transform.NewWriter(out, &asciisanitizer.Sanitizer{}) +} + func System() *IOStreams { terminal := ghTerm.FromEnv() @@ -501,12 +533,14 @@ func System() *IOStreams { } io := &IOStreams{ - In: os.Stdin, - Out: stdout, - ErrOut: stderr, - pagerCommand: os.Getenv("PAGER"), - term: &terminal, + In: os.Stdin, + Out: stdout, + ErrOut: stderr, + pagerCommand: os.Getenv("PAGER"), + term: &terminal, + sanitizeContent: true, } + io.ContentOut = newContentWriter(io.Out, io.sanitizeContent) stdoutIsTTY := io.IsStdoutTTY() stderrIsTTY := io.IsStderrTTY() @@ -557,10 +591,12 @@ func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { fd: 0, ReadCloser: io.NopCloser(in), }, - Out: &fdWriter{fd: 1, Writer: out}, - ErrOut: &fdWriter{fd: 2, Writer: errOut}, - term: &fakeTerm{}, + Out: &fdWriter{fd: 1, Writer: out}, + ErrOut: &fdWriter{fd: 2, Writer: errOut}, + term: &fakeTerm{}, + sanitizeContent: true, } + io.ContentOut = newContentWriter(io.Out, io.sanitizeContent) io.SetStdinTTY(false) io.SetStdoutTTY(false) io.SetStderrTTY(false) diff --git a/pkg/iostreams/untrusted.go b/pkg/iostreams/untrusted.go new file mode 100644 index 00000000000..0b5058d5b11 --- /dev/null +++ b/pkg/iostreams/untrusted.go @@ -0,0 +1,95 @@ +package iostreams + +import ( + "encoding/json" + "strings" + + "github.com/cli/go-gh/v2/pkg/asciisanitizer" + "golang.org/x/text/transform" +) + +// Untrusted wraps string content the application did not author: HTTP response +// bodies, file contents fetched from a remote, anything that originates outside +// the CLI. The raw bytes are unexported so the only ways out are the methods +// below. +// +// Untrusted satisfies fmt.Stringer, and String sanitizes, so any fmt print path +// (Fprint, Fprintf with %s or %v, Sprint) renders the content with ANSI escape +// sequences neutralized. The only way to reach the raw bytes is Raw, which is +// deliberately easy to grep for and is intended for non-terminal uses such as +// hashing, writing to a file, or piping to another program. +type Untrusted struct { + raw string +} + +// NewUntrusted labels a string as untrusted external content. +func NewUntrusted(s string) Untrusted { + return Untrusted{raw: s} +} + +// NewUntrustedBytes labels a byte slice as untrusted external content. +func NewUntrustedBytes(b []byte) Untrusted { + return Untrusted{raw: string(b)} +} + +// String returns the content with ANSI escape sequences neutralized. It is +// called automatically by the fmt package, so printing an Untrusted value is +// safe by default on every fmt path. +func (u Untrusted) String() string { + sanitized, _, err := transform.String(&asciisanitizer.Sanitizer{}, u.raw) + if err != nil { + return stripControl(u.raw) + } + return sanitized +} + +// Raw returns the unsanitized content. It is the explicit, greppable opt-out +// for non-terminal uses (hashing, writing to disk, piping). Never pass the +// result to a terminal writer. +func (u Untrusted) Raw() string { + return u.raw +} + +// Empty reports whether the content is empty, for callers that branch on +// presence without needing the bytes. +func (u Untrusted) Empty() bool { + return u.raw == "" +} + +// UnmarshalJSON lets a struct field typed as Untrusted be populated directly by +// json.Unmarshal, so provenance is preserved across a JSON decode. This is what +// lets a decoded field (e.g. a streamed log line) stay labeled when +// the surrounding response was never sanitized by the JSON transport. +func (u *Untrusted) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + u.raw = s + return nil +} + +// MarshalJSON emits the raw content as a JSON string so a value round-trips +// faithfully through encode/decode. +func (u Untrusted) MarshalJSON() ([]byte, error) { + return json.Marshal(u.raw) +} + +// RawBytes is Raw as a byte slice, for callers that need []byte (hashing, file +// writes). Same terminal caveat as Raw. +func (u Untrusted) RawBytes() []byte { + return []byte(u.raw) +} + +// stripControl is a defensive fallback used only if the sanitizing transform +// errors, which the asciisanitizer does not do in practice. It drops C0 control +// bytes other than tab, newline, and carriage return so the result can never +// carry an escape sequence. +func stripControl(s string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 && r != '\t' && r != '\n' && r != '\r' { + return -1 + } + return r + }, s) +} diff --git a/pkg/iostreams/untrusted_test.go b/pkg/iostreams/untrusted_test.go new file mode 100644 index 00000000000..c904da4cf04 --- /dev/null +++ b/pkg/iostreams/untrusted_test.go @@ -0,0 +1,79 @@ +package iostreams + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const esc = "\x1b" + +func TestUntrusted_String_sanitizes(t *testing.T) { + u := NewUntrusted("hello" + esc + "[31mRED" + esc + "[0m") + assert.NotContains(t, u.String(), esc) +} + +// The property that drove the design: fmt reflection must not leak the raw +// bytes through any verb. Because Untrusted implements Stringer, %s, %v, and the +// Print family all route through String() and sanitize. +func TestUntrusted_fmt_paths_never_leak(t *testing.T) { + u := NewUntrusted("x" + esc + "]0;title" + esc + "\\") + cases := map[string]string{ + "%s": fmt.Sprintf("%s", u), + "%v": fmt.Sprintf("%v", u), + "Sprint": fmt.Sprint(u), + "woven": fmt.Sprintf("by %s here", u), + } + for name, out := range cases { + t.Run(name, func(t *testing.T) { + assert.NotContains(t, out, esc) + }) + } +} + +func TestUntrusted_Raw_returnsExactBytes(t *testing.T) { + payload := "x" + esc + "[1mbold" + u := NewUntrusted(payload) + assert.Equal(t, payload, u.Raw()) + assert.Equal(t, payload, string(u.RawBytes())) +} + +func TestUntrustedBytes_roundTrip(t *testing.T) { + u := NewUntrustedBytes([]byte("plain text")) + assert.Equal(t, "plain text", u.String()) +} + +func TestStripControl_dropsC0KeepsWhitespace(t *testing.T) { + assert.Equal(t, "abc\td\ne", stripControl("a\x1bb\x07c\td\ne")) +} + +// The showcase property: an Untrusted struct field is populated by +// json.Unmarshal with provenance intact, so printing it later sanitizes even +// though the bytes arrived through a JSON decode. +func TestUntrusted_survivesJSONDecode(t *testing.T) { + var entry struct { + Content Untrusted `json:"content"` + } + payload := `{"content":"log\u001b[31mline"}` + require.NoError(t, json.Unmarshal([]byte(payload), &entry)) + assert.Equal(t, "log\x1b[31mline", entry.Content.Raw()) + assert.NotContains(t, entry.Content.String(), esc) +} + +func TestUntrusted_jsonRoundTrip(t *testing.T) { + u := NewUntrusted("x\x1b[0m") + b, err := json.Marshal(u) + require.NoError(t, err) + + var back Untrusted + require.NoError(t, json.Unmarshal(b, &back)) + assert.Equal(t, u.Raw(), back.Raw()) +} + +func TestUntrusted_Empty(t *testing.T) { + assert.True(t, NewUntrusted("").Empty()) + assert.False(t, NewUntrusted("x").Empty()) +} diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 5b05e1619e5..dd8dd590cac 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -12,6 +12,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/safeurl" ) const ( @@ -197,10 +198,12 @@ func (s searcher) Issues(query Query) (IssuesResult, error) { // // For more information, see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28. func (s searcher) search(query Query, result interface{}) (string, error) { - path := fmt.Sprintf("%ssearch/%s", ghinstance.RESTPrefix(s.host), query.Kind) - qs := url.Values{} - qs.Set("page", strconv.Itoa(query.Page)) - qs.Set("per_page", strconv.Itoa(query.Limit)) + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(s.host), "search", string(query.Kind)) + if err != nil { + return "", err + } + u.SetQuery("page", strconv.Itoa(query.Page)) + u.SetQuery("per_page", strconv.Itoa(query.Limit)) if query.Kind == KindIssues { // TODO advancedIssueSearchCleanup @@ -213,28 +216,27 @@ func (s searcher) search(query Query, result interface{}) (string, error) { } if !features.AdvancedIssueSearchAPI { - qs.Set("q", query.StandardSearchString()) + u.SetQuery("q", query.StandardSearchString()) } else { - qs.Set("q", query.AdvancedIssueSearchString()) + u.SetQuery("q", query.AdvancedIssueSearchString()) // TODO advancedIssueSearchCleanup if features.AdvancedIssueSearchAPIOptIn { // Advanced syntax should be explicitly enabled - qs.Set("advanced_search", "true") + u.SetQuery("advanced_search", "true") } } } else { - qs.Set("q", query.StandardSearchString()) + u.SetQuery("q", query.StandardSearchString()) } if query.Order != "" { - qs.Set(orderKey, query.Order) + u.SetQuery(orderKey, query.Order) } if query.Sort != "" { - qs.Set(sortKey, query.Sort) + u.SetQuery(sortKey, query.Sort) } - url := fmt.Sprintf("%s?%s", path, qs.Encode()) - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequest("GET", u.String(), nil) if err != nil { return "", err }