Skip to content

fix(classic): normalize config-profile payloads for the Classic API's non-spec XML handling (PI-827) - #310

Merged
neilmartin83 merged 3 commits into
mainfrom
fix/classic-profile-payload-escaping
Jul 30, 2026
Merged

fix(classic): normalize config-profile payloads for the Classic API's non-spec XML handling (PI-827)#310
neilmartin83 merged 3 commits into
mainfrom
fix/classic-profile-payload-escaping

Conversation

@neilmartin83

@neilmartin83 neilmartin83 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Problem

pro classic-macos-config-profiles apply --mobileconfig-file X.mobileconfig failed with an opaque HTTP 409 "Unable to update the database" whenever the mobileconfig contained any XML entity in a string value. Since plists must encode & and <, any profile with & or < in a value could not be uploaded — Setup Manager profiles, Santa CEL rules, CIS content, URLs with query strings. Backup round-trips (GET XML piped back into apply) failed identically. Signed and binary mobileconfigs produced malformed requests.

The server model (every clause wire-verified against two Jamf Pro 11.x tenants)

  1. Validation: the Classic API entity-decodes submitted <payloads> content once and 409s when the result contains a bare & or <. Spec-correct raw CDATA is therefore rejected for any plist containing &amp;/&lt; — escape-once is the only submittable wire form for such profiles. (A raw-first/retry-on-409 design was implemented and reverted in this branch's history once probes proved this.)
  2. Storage is per-payload-type (proven with a mixed profile — one wire body, two treatments): fragments of types the server re-renders (com.apple.ManagedClient.preferences custom settings including dict keys, com.apple.notificationsettings) are entity-decoded once — the escape stores them byte-exact. Every other payload type (TCC, direct com.apple.loginwindow, all mobile device payloads) is stored verbatim, keeping an extra entity layer — values with &/< there cannot be stored faithfully by any client, matching PI-827's history that Jamf's own exports can't be re-uploaded intact.

Fix (generator templates + internal/profileconvert)

  • normalizeClassicProfilePayloadsForSend — final body transform before every profile send: recover the true plist (text-form/backup input decoded once), guard ]]>, escape every & once, wrap in a single CDATA section.
  • verifyClassicProfileStored — after every successful write, GET the stored payload and semantically compare against the submitted plist (profileconvert.DiffPayloadValues: parse-based, masks server-rewritten Payload* metadata, tolerates the server's edge-whitespace trim). Server-side corruption surfaces as a stderr warning naming the exact dotted paths (e.g. PayloadContent[3].LoginwindowText) instead of passing silently.
  • Input handling (injectClassicFileFields): CMS-signed mobileconfigs auto-extract their inner plist (smallstep/pkcs7) with a note — the signature can't survive this API anyway; binary plists convert to XML; embedded CDATA sections are rewritten textually (stripCDATASections — not a plist re-serialise, which drops trailing whitespace in CDATA text).

Verification

Live on both tenants: all five reserved characters × every legal representation (raw/named entity/decimal/hex refs), literal entity text, entity-bearing dict keys, embedded CDATA sections, CEL expressions, signed profiles, binary plists, GET→apply round-trips. MCX-family content stores byte-exact with zero warnings through create/update/apply; the server-unfixable combinations (mobile + typed macOS payloads with &/< in values) create with deterministic warnings naming the affected values. On-device verification of decoded values via System Settings.

Full analysis: docs/solutions/conventions/classic-profile-payload-ampersand-escaping-2026-07-30.md

🤖 Generated with Claude Code

… non-spec XML handling (PI-827)

The Classic API does not treat <payloads> content per XML 1.0: it
entity-decodes CDATA content once (spec: zero) and text content twice
(spec: once). Any profile whose plist contains an entity — which means
any value with & or < in it — failed POST/PUT with an opaque HTTP 409
"Unable to update the database", including Jamf's own GET/backup XML
piped back into apply.

Fix (all in the classic generator templates):

- normalizeClassicProfilePayloadsForSend funnels every input form into
  the one wire format the server stores correctly: recover the true
  plist (text-form input is entity-decoded once), guard "]]>", escape
  every "&" once, wrap in CDATA. Applied as the final body transform in
  create/update/apply for both config-profile resources — after UUID
  injection, which needs the true-form plist.
- injectClassicFileFields now converts binary plists to XML and
  rewrites embedded CDATA sections as escaped character data
  (textually — howett.net/plist drops trailing whitespace in CDATA
  text, so no parse/re-serialise round trip).
- replaceClassicProfilePayload entity-guards "]]>" as a backstop.
- profileconvert.NormalizeXML added for the binary-plist conversion.

Wire-verified against Jamf Pro 11.x through the platform gateway:
all five reserved characters in every legal representation (raw, named
entity, decimal and hex refs), literal entity text, embedded CDATA
sections, and GET→apply round trips — byte-exact storage through
create, update, and apply. The server's only remaining transforms are
its own canonicalization (entities for & < >, literals for " ',
key sorting, edge-whitespace trim), which no client can influence.

Full analysis: docs/solutions/conventions/
classic-profile-payload-ampersand-escaping-2026-07-30.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@neilmartin83
neilmartin83 enabled auto-merge July 30, 2026 11:53
@neilmartin83
neilmartin83 disabled auto-merge July 30, 2026 12:26
@neilmartin83
neilmartin83 marked this pull request as draft July 30, 2026 12:26
…rect the PI-827 model

Follow-up to the initial escaping fix, driven by live probes across two
tenants that refined the server model:

- The 409 on raw content is a validation pass (fires for any payload
  type when one decode yields a bare & or <), so escape-once stays the
  wire form. Storage is per-payload-type: MCX custom settings and
  notification settings fragments are decoded once (escape stores them
  byte-exact); every other payload type — TCC, direct loginwindow, all
  mobile device payloads — is stored verbatim, keeping an extra entity
  layer. Values with & or < in those types cannot round-trip via any
  client. A raw-first/retry-on-409 design was implemented and reverted
  in this branch once probes showed raw never succeeds for
  entity-bearing profiles.
- verifyClassicProfileStored: after every successful profile write, GET
  the stored payload and semantically compare against the submitted
  plist (profileconvert.DiffPayloadValues — masks server-rewritten
  Payload* metadata, tolerates edge-whitespace trim). Server-side
  corruption now surfaces as a stderr warning naming the exact paths
  instead of passing silently.
- CMS-signed mobileconfigs are detected and their inner plist extracted
  (smallstep/pkcs7) with a note — the signature cannot survive this API.
  Binary plists are converted to XML.

Live-verified: MCX reserved-character matrix byte-exact with zero
warnings (osx create/update/apply, backup round-trip); mobile matrix and
signed Login Window profiles create with deterministic warnings naming
the affected values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@neilmartin83
neilmartin83 enabled auto-merge July 30, 2026 13:29
@neilmartin83
neilmartin83 merged commit b66320f into main Jul 30, 2026
1 check passed
@neilmartin83
neilmartin83 deleted the fix/classic-profile-payload-escaping branch July 30, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants