Harden G1.x rewriter pipeline: 5 correctness/security fixes
Summary
Followups to the G1.1–G1.3 architectural review. Stacks on #365 (G1.3) — base is the G1.3 branch so the JsonPathReplacer changes here build on the file landed in that PR. After #365 merges, this PR can be retargeted at master (or just merged through after a rebase).
Zero wire-literal changes — SquidWeb unaffected. Purely internal correctness + security hardening.
What's fixed
| # | Concern | What broke | Fix |
|---|---|---|---|
| #1 (closed) | JSON namespace pollution | A variable named Squid.Deployment.Id would clobber an operator's literal JSON leaf at the same path — silent corruption with no operator-facing cause |
JsonPathReplacer.TryFindVariable skips dot-form lookups starting with Squid.. Colon form (Squid:X:Y) stays as the explicit-intent escape hatch |
| #2 | G1.3 non-atomic write | A 50MB appsettings.json write killed mid-way left the operator with a corrupted config |
New shared EncodingPreservingFileIO.WriteAllTextAtomic (sibling temp + rename), reused by G1.1 + G1.2 + G1.3 |
| #3 | G1.3 BOM loss | VS-generated appsettings.json with UTF-8 BOM lost the BOM on every rewrite — polluted deploy diffs |
Same shared helper exposes ReadAllTextPreservingEncoding (extracted from G1.1's local copy); G1.3 + G1.1 both use it |
| #4 | JSON encoder over-escape | Default System.Text.Json encoder turns <, >, &, + into \uXXXX — operator's URL/HTML strings unreadable in diffs |
WriteOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping. Safe because target is IConfiguration, never injected into HTML |
| #5 (closed) | Symlink-escape attack | Malicious package can plant evil.config -> /etc/passwd; on Linux Tentacle running as root, the rewriter overwrites host files |
GlobMatcher adds match-level layer: resolve canonical real path via FileInfo.ResolveLinkTarget; drop matches whose real path escapes rootDir. Fail-closed on resolution errors |
What's in the box
| Layer | File |
|---|---|
| Shared file-IO primitive |
src/Squid.Calamari/Commands/Common/EncodingPreservingFileIO.cs (new) |
| JSON namespace + encoder | src/Squid.Calamari/Commands/StructuredConfig/JsonPathReplacer.cs |
| G1.3 atomic + BOM | src/Squid.Calamari/Commands/StructuredConfig/StructuredConfigVariablesStep.cs |
| G1.1 migration to shared | src/Squid.Calamari/Commands/Substitution/SubstituteInFilesStep.cs |
| G1.2 migration to shared | src/Squid.Calamari/Commands/Configuration/XdtTransformer.cs |
| Symlink sandbox | src/Squid.Calamari/Commands/Substitution/GlobMatcher.cs |
| Tests — shared helper (8) |
tests/Squid.Calamari.Tests/Calamari/Commands/Common/EncodingPreservingFileIOTests.cs (new) |
| Tests — JSON guard + encoder (5) | tests/Squid.Calamari.Tests/Calamari/Commands/StructuredConfig/JsonPathReplacerTests.cs |
| Tests — symlink (2) | tests/Squid.Calamari.Tests/Calamari/Commands/Substitution/GlobMatcherTests.cs |
Test plan
-
Squid.Calamari.Tests: 246/246 (was 230; +16 new for the 5 fixes — 13 directly + 3 round-trip integrations) -
Squid.UnitTests cross-project wire-contract: 5615/5615 — no regression on the 12 G1.1/G1.2/G1.3 drift detectors -
dotnet buildsolution-wide: 0 errors -
Symlink-escape test guarded behind OperatingSystem.IsWindows()skip — symlink creation needs elevation on Windows pre-1809; cross-OS dev hosts stay green -
Staging IIS deploy of an ASP.NET Core app with appsettings.Production.jsoncontainingSquid.*literals (drift-detection scenario from #1 (closed)) + URL connection strings (encoder scenario from #4)
Non-breaking guarantee
| Surface | Status |
|---|---|
Wire literals (Squid.Action.IISWebSite.*) |
Unchanged |
| Operator-facing variable names | Unchanged |
| Step toggles + behavior on the happy path | Unchanged |
| Step output / log format | Unchanged |
| Frontend (SquidWeb) wire-literal references | None touched — confirmed via grep against SquidWeb/src/pages/project-detail/step-editors/iis/DeployToIisEditor.tsx
|
The behavior changes (Squid.* skip, symlink reject) are corrections — they reject inputs that produced silent corruption or security bypass before. No correct deploy ever depended on those broken paths.