From f96338110b618fffb4b061d5b2c282b2a7d180da Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Fri, 24 Jul 2026 01:20:02 -0400 Subject: [PATCH] fix(codersdk): reject trailing data after closing single quote in env import (#27474) --- codersdk/usersecretsimport.go | 25 +++++++++++++++---------- codersdk/usersecretsimport_test.go | 21 ++++++++++++++++++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go index 75aad537e0..b7c020ad46 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -191,11 +191,7 @@ func parseEnvValue(rhs string, lineNum int) (string, error) { } return unescapeDoubleQuoted(inner), nil case '\'': - inner, ok := quotedInner(v, '\'') - if !ok { - return "", xerrors.Errorf("line %d: missing closing single quote", lineNum) - } - return inner, nil + return singleQuotedInner(v, lineNum) default: return strings.TrimSpace(v), nil } @@ -222,12 +218,21 @@ func hasOddBackslashRun(s string, before int) bool { return count%2 == 1 } -func quotedInner(v string, quote byte) (string, bool) { - trimmed := strings.TrimRight(v, " \t") - if len(trimmed) < 2 || trimmed[len(trimmed)-1] != quote { - return "", false +// singleQuotedInner returns the content between the opening single +// quote at index 0 and the first closing single quote. Single quotes +// have no escape sequences, so the first quote after the opener always +// closes the value. Only whitespace may follow the closing quote. +func singleQuotedInner(v string, lineNum int) (string, error) { + for i := 1; i < len(v); i++ { + if v[i] != '\'' { + continue + } + if strings.Trim(v[i+1:], " \t") != "" { + return "", xerrors.Errorf("line %d: unexpected data after closing single quote", lineNum) + } + return v[1:i], nil } - return trimmed[1 : len(trimmed)-1], true + return "", xerrors.Errorf("line %d: missing closing single quote", lineNum) } func unescapeDoubleQuoted(s string) string { diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 5dea3480d9..7faa8e0606 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -29,6 +29,11 @@ func TestParseSecretsFileEnv(t *testing.T) { `DQ_CARRIAGE="a\rb"`, `DQ_UNKNOWN="x\zy"`, `SQUOTED='literal \n no escape'`, + "SQ_TRAILING_WS='abc' \t", + "SQ_EMPTY=''", + `SQ_DOUBLE_INSIDE='a"b'`, + `SQ_HASH='#not a comment'`, + "SQ_UNICODE='héllo 世界'", "EQ_IN_VALUE=a=b=c", "HASH=value # kept literal", "UNICODE=héllo 世界 café", @@ -54,6 +59,11 @@ func TestParseSecretsFileEnv(t *testing.T) { {Name: "DQ_CARRIAGE", EnvName: "DQ_CARRIAGE", Value: "a\rb"}, {Name: "DQ_UNKNOWN", EnvName: "DQ_UNKNOWN", Value: `x\zy`}, {Name: "SQUOTED", EnvName: "SQUOTED", Value: `literal \n no escape`}, + {Name: "SQ_TRAILING_WS", EnvName: "SQ_TRAILING_WS", Value: "abc"}, + {Name: "SQ_EMPTY", EnvName: "SQ_EMPTY", Value: ""}, + {Name: "SQ_DOUBLE_INSIDE", EnvName: "SQ_DOUBLE_INSIDE", Value: `a"b`}, + {Name: "SQ_HASH", EnvName: "SQ_HASH", Value: "#not a comment"}, + {Name: "SQ_UNICODE", EnvName: "SQ_UNICODE", Value: "héllo 世界"}, {Name: "EQ_IN_VALUE", EnvName: "EQ_IN_VALUE", Value: "a=b=c"}, {Name: "HASH", EnvName: "HASH", Value: "value # kept literal"}, {Name: "UNICODE", EnvName: "UNICODE", Value: "héllo 世界 café"}, @@ -92,7 +102,15 @@ func TestParseSecretsFileEnvErrors(t *testing.T) { {name: "UnterminatedDouble", content: `KEY="oops`, errMsgs: []string{"missing closing double quote"}}, {name: "EscapedDoubleQuoteNotClosing", content: `KEY="oops\"`, errMsgs: []string{"missing closing double quote"}}, {name: "DoubleQuoteTrailingData", content: `KEY="ok" # comment`, errMsgs: []string{"unexpected data after closing double quote"}}, - {name: "UnterminatedSingle", content: `KEY='oops`, errMsgs: []string{"missing closing single quote"}}, + {name: "UnterminatedSingle", content: `KEY='oops`, errMsgs: []string{"missing closing single quote", "line 1"}}, + {name: "SingleQuoteOnly", content: `KEY='`, errMsgs: []string{"missing closing single quote", "line 1"}}, + {name: "SingleQuoteTrailingComment", content: `KEY='abc' # 'note'`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}}, + {name: "SingleQuoteEmbeddedQuote", content: `KEY='a'b'`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}}, + {name: "SingleQuotePairAfterClose", content: `KEY='a' 'b'`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}}, + {name: "SingleQuoteTrailingData", content: `KEY='abc' extra`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}}, + {name: "SingleQuoteAdjacentQuoted", content: `KEY='it''s'`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}}, + {name: "SingleQuoteTrailingDataNoSpace", content: `KEY=''extra`, errMsgs: []string{"unexpected data after closing single quote", "line 1"}}, + {name: "SingleQuoteErrorOnLaterLine", content: "OK=fine\nKEY='abc' extra", errMsgs: []string{"unexpected data after closing single quote", "line 2"}}, {name: "DuplicateKey", content: "DUP=a\nDUP=b", errMsgs: []string{"duplicate key", "line 2"}}, } for _, tt := range tests { @@ -254,6 +272,7 @@ func FuzzParseSecretsFile(f *testing.F) { f.Add("env", "NOEQUALS") f.Add("env", "=value") f.Add("env", `KEY="ok" # trailing`) + f.Add("env", `KEY='ok' extra`) f.Add("env", "DUP=a\nDUP=b") // json - valid f.Add("json", `{"A":"1","B":"two"}`)