Skip to content
quarry Artifact [be1e0eabb2]
Login

Artifact [be1e0eabb2]

Artifact be1e0eabb2efcffc80a521e4d8735517cfdc47f609c662ce298258e2db5ccff1:


package zitcli

import (
	"fmt"
	"strings"
)

// quarryFlags is every option string quarry itself passes to zit.
//
// It is a CLOSED SET, and that is the whole mechanism: quarry's flags
// are string literals in this package, while an attacker's arrive in a
// URL. At the argv layer the two are indistinguishable — both are just
// strings beginning with a dash — so the only thing that separates them
// is that one set can be written down in advance and the other cannot.
//
// Adding a flag to a zit invocation means adding it here. A missing
// entry fails that command loudly rather than silently, which is the
// direction this guard must fail in.
var quarryFlags = map[string]bool{
	"--branch": true, "--brief": true, "--comment": true, "--event": true,
	"--force": true, "--from": true, "--into": true, "--long": true,
	"--mimetype": true, "--name": true, "--probe": true, "--propagate": true,
	"--raw": true, "--reply-to": true, "--structural": true, "--time": true,
	"--title": true, "--to": true, "--user": true,
	"-R": true, "-c": true, "-m": true, "-n": true,
	"--no-cache": true, "--limit": true, "--depth": true, "--out": true,
}

// quarryJoinedFlags is the smaller set quarry passes in zit's JOINED
// form, "--under=internal", where the part after the "=" is data the
// caller supplied.
//
// Separate from quarryFlags on purpose. A joined boolean is exactly
// what an injected URL segment looks like — "--no-cache=1" was the
// third row of the guard's own test — so only flags quarry actually
// spells this way are read for their name. The value needs no check
// of its own: it is one argv token bound to a flag name in this set,
// so it can no more become a second option than "--under internal"
// could.
var quarryJoinedFlags = map[string]bool{
	"--under": true, "--since": true, "--verify": true, "--store": true,
}

// checkArgs refuses an argument that looks like an option and is not one
// of quarry's own, up to the end-of-options marker.
//
// THE VULNERABILITY IT CLOSED. The file, tree, doc and history routes
// put a request's path and version segments into argv as positional
// arguments. zit parsed any argument beginning with "-" as an option,
// and had no "--" end-of-options marker to stop it. So a URL segment
// WAS a flag:
//
//	GET /api/v1/r/<repo>/file/trunk/--help   -> 200, zit's help as file content
//
// Anonymous, on every repository, needing no capability. Found by jva,
// who filed it as a cosmetic 500 and then re-classified it when their
// adversarial subagent asked what was on the other end of the 500. I
// had investigated the same ticket hours earlier, found the missing
// "--", filed it upstream, and never asked what the segment BECOMES.
//
// Why here and not at each caller: a dozen methods take a user-supplied
// path, version, ref or name, and the next one added would not know to
// guard itself. This is the single place every invocation passes
// through, so a new call site is covered the day it is written.
//
// Why not simply reject every leading dash: quarry passes its own flags
// through the same door.
//
// WHAT THE MARKER CHANGED. zit 0.3.25 honours "--" in every verb, so
// the call sites that carry a URL segment now put one in front of it
// and the scan stops there: past the marker nothing is an option, and
// a name beginning with a dash — which is what the whole upstream fix
// exists to make reachable — is passed through rather than refused.
// The guard stays in front of it because the marker is a thing a call
// site remembers and this is a thing nobody has to: an invocation that
// forgets the marker is still refused rather than exploited.
func checkArgs(args []string) error {
	for _, a := range args {
		if a == "--" {
			return nil
		}
		if !strings.HasPrefix(a, "-") || quarryFlags[a] || joinedFlag(a) {
			continue
		}
		// A negative number is an operand, not an option: zit takes
		// them for limits and offsets, and refusing them would break
		// callers for no security gain.
		if isNegativeNumber(a) {
			continue
		}
		return fmt.Errorf("%w: refusing to pass %q to zit as an option", ErrUnsafeArgument, a)
	}
	return nil
}

// joinedFlag reports an argument of the form "--<flag>=<value>" whose
// flag is one quarry passes that way.
//
// This is what broke every subdirectory page on the forge. The guard
// compared the WHOLE token, so "--under=internal" was not "--under",
// the tree listing was refused, and tree.list turned the refusal into
// a 404 — on every repository, for every directory, while the suite
// stayed green because nothing listed a directory through a real zit.
// TestListingASubdirectory is that missing row.
func joinedFlag(a string) bool {
	if !strings.HasPrefix(a, "--") {
		return false
	}
	name, _, ok := strings.Cut(a, "=")
	return ok && quarryJoinedFlags[name]
}

func isNegativeNumber(s string) bool {
	if len(s) < 2 || s[0] != '-' {
		return false
	}
	for _, r := range s[1:] {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}