Part 3: Supporting Non-ASCII Branch Names in GitLab Workspaces — AppSec CWE-78 & CWE-116 Deep Dive
Series Context: Part 3 of 10 in GitLab Core Engineering: Architecture, MCP & CI/CD Deep Dives. Featured MR: gitlab-org/gitlab!262103 · Work Item: #596326 The Bug: Why UTF-8 Branches Failed to Clone in GitLa
Series Context: Part 3 of 10 in GitLab Core Engineering: Architecture, MCP & CI/CD Deep Dives.
Featured MR:gitlab-org/gitlab!262103· Work Item:#596326
The Bug: Why UTF-8 Branches Failed to Clone in GitLab Workspaces
GitLab's Gitlab::GitRefValidator supports valid UTF-8 branch names such as feature/günter-überarbeitung or ветка/тест-機能. However, provisioning a GitLab Workspace on those branches failed during post-start cloning inside the container.
The culprit lived in ee/lib/remote_development/workspace_operations/create/internal_poststart_commands_inserter.rb:
format(
INTERNAL_POSTSTART_COMMAND_CLONE_PROJECT_SCRIPT,
project_cloning_successful_file: Shellwords.shellescape(project_cloning_successful_file),
clone_dir: Shellwords.shellescape(clone_dir),
project_ref: Shellwords.shellescape(project_ref),
project_url: Shellwords.shellescape(project_url),
clone_depth_option: clone_depth_option
)
Look closely at what Ruby's stdlib Shellwords.shellescape actually does. Internally, Ruby's shellescape runs:
str.gsub!(/[^A-Za-z0-9_\-.,:+\/@\n]/, "\\\\\\&")
Notice what isn't in that allowlist: any multibyte / non-ASCII UTF-8 character (ü, Cyrillic, CJK, accents). On a byte-by-byte or non-ASCII basis, Shellwords.shellescape("feature/günter") outputs backslash-escaped characters (feature/g\ünter). Even subtle shell locale differences inside minimal Alpine/Debian workspace containers, combined with git ref handling and template construction, led to mangled refs and broken workspace clones.
Meanwhile, project_ref is user-controlled input interpolated directly into a shell command template (git clone ... --branch %<project_ref>s ...). Any escaping change here sits squarely on a command injection (CWE-78) trust boundary—meaning AppSec scrutiny is mandatory.
Designing shellescape_ref: Minimally Diffable Against Ruby Stdlib
During security review with @erran and @cbonk, we converged on a crucial principle for security-sensitive sanitization helpers: do not invent a clever custom regex from scratch if an AppSec reviewer has to re-prove its safety. Instead, copy Ruby stdlib's Shellwords.shellescape verbatim and change only the single POSIX character class needed to pass non-ASCII characters through:
# Copy of Shellwords.shellescape leaving non-ASCII characters unescaped for git clone --branch.
# Differs only by [:^ascii:] from stdlib:
# https://github.com/ruby/ruby/blob/2b0b7728dc7f0561c35c3d8c4489945c94b783ad/lib/shellwords.rb#L158-L179
#
# WARNING: Output is only safe in an unquoted shell position (e.g. `--branch %<project_ref>s`).
# Quoting it in a template preserves literal backslashes and breaks non-safe branch names.
#
# @see https://gitlab.com/gitlab-org/gitlab/-/work_items/596326
# @param [String] str
# @return [String]
def self.shellescape_ref(str)
str = str.to_s
return +"''" if str.empty?
raise ArgumentError, "NUL character" if str.index("\0")
str = str.dup
str.gsub!(%r{[^A-Za-z0-9_\-.,:+/@\n[:^ascii:]]}, "\\\\\\&")
str.gsub!(/\n/, "'\n'")
str
end
Why [:^ascii:] (and Not [:ascii:]) Inside [^...]?
At one point in review, an AppSec discussion asked whether [:ascii:] vs [:^ascii:] had a negation typo. Let's trace Ruby's Onigmo character class math carefully because getting this backwards is catastrophic:
- We want to match and escape any character that is:
- NOT an ASCII safe character (
A-Za-z0-9_-.,:+/@\n), AND - NOT a non-ASCII character (
[:^ascii:]).
- NOT an ASCII safe character (
- Because the outer class is a negated set
[^ ... ], listing[:^ascii:]inside[^ ... ]means "do NOT match non-ASCII characters"—i.e., leave non-ASCII characters untouched, while matching and backslash-escaping every ASCII metacharacter (;,&,|,`,$,(,),',", spaces, tabs,>,<,\). - Conversely, putting
[:ascii:]inside[^...]would mean "do NOT match ASCII"—which would leave;and$()completely unescaped! Always verify double negations in character classes with executable round-trip tests.
Every Detail Preserved From Stdlib
-
Empty string ->
"''": Ensures an empty branch doesn't collapse--branch <url>into consuming the URL as the branch argument. -
NUL byte guard (
\0): RaisesArgumentError, "NUL character"immediately (identical to Ruby 3.xShellwords.shellescape). -
Newlines ->
"'\n'": Prevents backslash-newline line continuation inside/bin/sh. -
Unquoted-position warning: Backslash escaping only works when interpolated unquoted (
--branch %<project_ref>s). Inside'...'or"...", backslashes become literal characters in the branch name!
Two AppSec Findings That Made the Fix Stronger
After @erran performed an AppSec triage review, two concrete findings surfaced under CWE-78 and CWE-116—and neither had been obvious before review!
1. CWE-78 Even Inside an RSpec Spec File!
Earlier in the PR, to prove that POSIX /bin/sh actually parsed the escaped --branch string back into the exact original UTF-8 project_ref, the spec executed:
# Flagged by AppSec as CWE-78 (OS Command Injection) in test code
output, status = Open3.capture2("sh", "-c", "printf '%s' #{escaped_ref}")
Why did AppSec flag Open3.capture2("sh", "-c", ...) even inside a test (_spec.rb)?
- Test suites are scanned by SAST rules (
CWE-78: Improper Neutralization of Special Elements used in an OS Command)—and if a regression ever brokeshellescape_ref, running the spec would actually execute injected shell commands on CI runners! - Spawning subprocesses in a
fast_spec_helperunit test slows specs down unnecessarily.
The fix: remove require "open3" and process spawning completely, and verify the exact contract deterministically in Ruby using Shellwords.shellsplit:
it "matches Shellwords.shellescape except for non-ASCII characters and round-trips via Shellwords.shellsplit" do
escaped_url = Regexp.escape(Shellwords.shellescape(project.http_url_to_repo))
escaped_ref = clone_command[:exec][:commandLine][/--branch (.*) "#{escaped_url}"/m, 1]
expected_escaped = if project_ref.empty?
"''"
else
project_ref.each_char.map do |char|
if char.ascii_only?
char == "\n" ? "'\n'" : Shellwords.shellescape(char)
else
char
end
end.join
end
expect(escaped_ref).to eq(expected_escaped)
expect(Shellwords.shellsplit(escaped_ref)).to eq([project_ref])
end
That single parameterized block across 10 adversarial inputs ("", "feature/günter-überarbeitung", "ветка/тест-機能", "feature/\\ü", newlines, ;&|><, $(whoami), `id`, quotes, tabs) proves both character-level equivalence and shell token round-trip safety without ever forking /bin/sh.
2. CWE-116 Explicit Security Review Comment
On security-reviewed functions in InternalPoststartCommandsInserter, GitLab documents every interpolated template parameter in a # SECURITY REVIEWED: header block. We updated the comment to explicitly state both the escaping rule and its template invariant:
-
project_refescapes ASCII outsideA-Za-z0-9_-.,:+/@\n, passes non-ASCII through, and stays unquoted inINTERNAL_POSTSTART_COMMAND_CLONE_PROJECT_SCRIPT.
Following commit ff1827c321, AppSec (@erran) applied ~"security-review::completed" and ~"AppSecWorkflow::complete" with a green CI pipeline (#3053234994).
Takeaways for GitLab Engineers
-
Diff against stdlib: When adapting
Shellwords(or any security sanitizer), match stdlib structure line-for-line so security reviewers can audit the exact delta in seconds. -
Never spawn
sh -cin specs to test escaping: Use pure-Ruby parser round-trips (Shellwords.shellsplit) so SAST scanners stay clean and tests never execute broken inputs. -
Document template-context invariants: Escaping without documenting unquoted vs single-quoted position invites someone later to add quotes
"%<project_ref>s"and break escaping silently.
Series Navigation
-
Part 1: Taming
EE::Project& ExtractingAi::Catalog(!269060) -
Part 2: Decoupling
EE::GroupRouting viaAi::DuoSettings::Paths(!269059) -
Part 3 (This Article): Escaping Non-ASCII Git Refs in Workspaces — AppSec CWE-78 & CWE-116 (
!262103) -
Part 4: Exposing Grape Routes to MCP —
list_pipeline_schedules& Auth Traps (!264856) -
Part 5: Vacuous RSpec Mocks & Live Upstream Flag Rebases (
!269061) -
Part 6: Sequential
description_edits& Quick-Action Defense insave_work_item(!264862) -
Part 7: Self-Correcting MCP Errors & JSON Schema Fixes (
!264852,!264853,!264864) -
Part 8: Consolidating
SavePipelineService& Active Polling Hints (!264858,!264863) -
Part 9: Surviving
Dangerfile, Commit Trailers & Predictive CI Pipelines -
Part 10: Security Cleanups, Organization
empty?& Review Etiquette (!261178,!261030,!264859)
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.