v0.281.0: 'Done' asks the probe first; sign-up closed after the first admin (decision 47); R-713 code-bound values refused, ${NAME|base64}
gates / gates (push) Successful in 23s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-29 09:57:58 +02:00
parent 3a12c7341a
commit 149467c795
28 changed files with 1928 additions and 15 deletions
+44 -3
View File
@@ -1,6 +1,7 @@
package stacks
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
@@ -60,23 +61,63 @@ func expandAfterInstall(cmd []string, allowed []string, env map[string]string) (
for _, n := range allowed {
ok[n] = true
}
var missing []string
var missing, unsafe []string
out := make([]string, len(cmd))
for i, a := range cmd {
// R-713 (v0.281.0): where does the value land? Its OWN argument ("${X}") or a plain argument
// ("--password=${X}", "admin:${X}") cannot be read as code — any value. Text with spaces, quotes or
// brackets around it ('… "${X}" …' in an Elixir or Python string) can: there the raw value must not hold a
// quote, a backslash, $, {, }, a backtick or a line break. `${X|base64}` is always safe (letters, digits,
// +, /, =): the template decodes it in its own code (claper).
codeShaped := !argumentShaped(a)
out[i] = os.Expand(a, func(k string) string {
if !ok[k] || env[k] == "" {
name, enc, _ := strings.Cut(k, "|")
if !ok[name] || env[name] == "" {
missing = append(missing, name)
return ""
}
v := env[name]
switch enc {
case "":
case "base64":
return base64.StdEncoding.EncodeToString([]byte(v))
default:
missing = append(missing, k)
return ""
}
return env[k]
if codeShaped && strings.ContainsAny(v, codeUnsafeChars) {
unsafe = append(unsafe, name)
}
return v
})
}
if len(missing) > 0 {
return nil, fmt.Errorf("after_install: %v not declared in env or has no value — not run", missing)
}
if len(unsafe) > 0 {
return nil, fmt.Errorf("after_install: the value of %v would be read as code (it holds a quote, a backslash, $, {, }, a backtick or a line break) — not run; the template should pass it as its own argument or as ${NAME|base64}", unsafe)
}
return out, nil
}
// codeUnsafeChars can end a string or start an interpolation in the code a command carries.
const codeUnsafeChars = "'\"\\$`{}\n\r\x00"
// argumentShaped: with every ${…} removed, the element is empty or plain argument text (a flag, a name, a "user:"
// prefix) — nothing that can open or close a string in code.
func argumentShaped(a string) bool {
rest := os.Expand(a, func(string) string { return "" })
for _, r := range rest {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case strings.ContainsRune("-_.:=/@+,", r):
default:
return false
}
}
return true
}
// RunAfterInstall runs the app's after_install once, after a FRESH install (main.go's deploy-done hook).
// Returns (ran, error). Records the outcome in app.yaml either way.
func (m *Manager) RunAfterInstall(name string, wait time.Duration) (bool, error) {