Fix mobile onboarding URL handoff sanitization
Some checks are pending
Canonical Governance / governance (push) Waiting to run
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (push) Waiting to run

This commit is contained in:
rcourtman 2026-07-07 15:12:59 +01:00
parent e1cddca2d1
commit 57139c65c5
10 changed files with 156 additions and 11 deletions

View file

@ -696,6 +696,11 @@ surface and no new `internal/api/` lifecycle handler.
lifecycle surfaces may display contact email when supplied by the shared
auth boundary, but they must not reinterpret SSO or Stripe email as the
canonical user identifier for setup, install, or fleet-management actions.
Mobile onboarding payload sanitization in
`internal/api/onboarding_handlers.go` remains API/relay-mobile ownership:
omitting a non-HTTPS `instance_url` from a QR/deep-link is a Pulse web
handoff decision only and must not change install-command URLs,
auto-register behavior, agent connection URLs, or lifecycle admission.
Approved-action tool invocation parsing in `internal/api/router_routes_ai_relay.go`
is not an agent-lifecycle route grammar: lifecycle-adjacent setup or repair
flows that execute governed Pulse tools must inherit

View file

@ -157,6 +157,10 @@ onboarding_not_ready` diagnostic response when Remote Access, relay
registration, or the dedicated Pulse Mobile credential is incomplete, while
`frontend-modern/src/api/onboarding.ts` and relay settings consumers may only
surface those diagnostics and must not synthesize partial pairing payloads.
The `instance_url` Pulse web handoff field is optional and may only be emitted
when the resolved Pulse URL is an absolute `https://` URL; non-HTTPS admin
URLs must be omitted from QR/deep-link payloads with a warning diagnostic so
mobile pairing does not fail on a web-handoff-only field.
Proxy-auth administrator evaluation is a shared auth/API contract. Once
`PROXY_AUTH_ROLE_HEADER` and `PROXY_AUTH_ADMIN_ROLE` are configured,
@ -6737,7 +6741,10 @@ reported a connected `instance_id`, or the request lacks the dedicated
server-minted mobile credential. A successful QR/deep-link response therefore
continues to mean the payload contains the exact relay registration and token
material Pulse Mobile can validate, while settings surfaces consume the
diagnostics for operator-visible readiness copy.
diagnostics for operator-visible readiness copy. The optional Pulse web
handoff URL must not weaken that contract: if the resolved public URL is not
HTTPS, the response omits `instance_url`, keeps pairing material usable, and
returns an `instance_url_not_https` warning for settings copy.
The shared security token contract now also includes single-record metadata
reads. `internal/api/security_tokens.go`,
`internal/api/router_routes_auth_security.go`,

View file

@ -446,6 +446,11 @@ recovery scope, or a storage/recovery-owned secret source.
resulting Assistant context if exposed by another surface, but must not
treat resource-to-action-target coercion as recovery scope, backup
ownership, restore authorization, or storage-provider identity.
Mobile onboarding QR/deep-link `instance_url` sanitization in
`internal/api/onboarding_handlers.go` is also adjacent API/relay-mobile
ownership. Storage and recovery consumers must not treat an omitted
non-HTTPS Pulse web handoff URL as storage source identity, recovery
endpoint identity, backup readiness, or restore-scope evidence.
AI provider registry and `/api/settings/ai` credential-shape changes in
`internal/api/ai_handlers.go` are adjacent runtime configuration only:
provider ids, default model routes, provider endpoints, API-key fields, and

View file

@ -52,6 +52,29 @@ describe('OnboardingAPI', () => {
});
});
it('accepts pairing payloads without an optional Pulse web handoff URL', async () => {
const mockResponse: OnboardingQRResponse = {
schema: 'pulse-mobile-onboarding-v1',
instance_id: 'inst-123',
relay: { enabled: true, url: 'wss://relay.example.com/ws/app' },
auth_token: 'token-123',
deep_link: 'pulse://connect?instance_id=inst-123',
diagnostics: [
{
code: 'instance_url_not_https',
severity: 'warning',
message: 'Pulse web link is not included in this pairing code because the resolved Pulse URL is not HTTPS.',
field: 'instance_url',
},
],
};
vi.mocked(apiFetch).mockResolvedValueOnce(
new Response(JSON.stringify(mockResponse), { status: 200 }),
);
await expect(OnboardingAPI.getQRPayload('token-123')).resolves.toEqual(mockResponse);
});
it('throws readiness diagnostics when the backend refuses an incomplete pairing payload', async () => {
const diagnostics = [
{

View file

@ -18,7 +18,7 @@ export interface OnboardingDiagnostic {
export interface OnboardingQRResponse {
schema: string;
instance_url: string;
instance_url?: string;
instance_id?: string;
relay: OnboardingRelayDetails;
auth_token: string;

View file

@ -37,7 +37,7 @@ describe('Onboarding QR payload contract', () => {
expect(onboardingSource).toContain('export interface OnboardingQRResponse');
expect(onboardingSource).toContain('schema: string;');
expect(onboardingSource).toContain('instance_url: string;');
expect(onboardingSource).toContain('instance_url?: string;');
expect(onboardingSource).toContain('relay: OnboardingRelayDetails;');
expect(onboardingSource).toContain('auth_token: string;');
expect(onboardingSource).toContain('deep_link: string;');

View file

@ -8997,6 +8997,45 @@ func TestContract_OnboardingQRResponseJSONSnapshot(t *testing.T) {
assertJSONSnapshot(t, got, want)
}
func TestContract_OnboardingQRResponseOmitsEmptyInstanceURLJSONSnapshot(t *testing.T) {
payload := onboardingQRResponse{
Schema: onboardingSchemaVersion,
InstanceID: "relay_abc123",
Relay: onboardingRelayDetails{
Enabled: true,
URL: "wss://relay.example.test/ws/app",
},
AuthToken: "token-123",
DeepLink: "pulse://connect?schema=pulse-mobile-onboarding-v1&instance_id=relay_abc123&relay_url=wss%3A%2F%2Frelay.example.test%2Fws%2Fapp&auth_token=token-123",
Diagnostics: []onboardingDiagnostic{
{
Code: "instance_url_not_https",
Severity: "warning",
Field: "instance_url",
Expected: "https://...",
Received: "http://pulse.local.test",
Message: "Pulse web link is not included in this pairing code because the resolved Pulse URL is not HTTPS. Pairing can continue, but opening Pulse web from the phone requires an HTTPS Pulse URL.",
},
},
}.normalizeCollections()
got, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal onboarding qr response without instance_url: %v", err)
}
const want = `{
"schema":"pulse-mobile-onboarding-v1",
"instance_id":"relay_abc123",
"relay":{"enabled":true,"url":"wss://relay.example.test/ws/app"},
"auth_token":"token-123",
"deep_link":"pulse://connect?schema=pulse-mobile-onboarding-v1\u0026instance_id=relay_abc123\u0026relay_url=wss%3A%2F%2Frelay.example.test%2Fws%2Fapp\u0026auth_token=token-123",
"diagnostics":[{"code":"instance_url_not_https","severity":"warning","message":"Pulse web link is not included in this pairing code because the resolved Pulse URL is not HTTPS. Pairing can continue, but opening Pulse web from the phone requires an HTTPS Pulse URL.","field":"instance_url","expected":"https://...","received":"http://pulse.local.test"}]
}`
assertJSONSnapshot(t, got, want)
}
func TestContract_OnboardingNotReadyResponseJSONSnapshot(t *testing.T) {
payload := onboardingNotReadyResponse{
Code: onboardingNotReadyCode,

View file

@ -25,7 +25,7 @@ type onboardingRelayDetails struct {
type onboardingQRResponse struct {
Schema string `json:"schema"`
InstanceURL string `json:"instance_url"`
InstanceURL string `json:"instance_url,omitempty"`
InstanceID string `json:"instance_id,omitempty"`
Relay onboardingRelayDetails `json:"relay"`
AuthToken string `json:"auth_token"`
@ -288,7 +288,8 @@ func (r *Router) buildOnboardingPayload(req *http.Request, relayCfg *relay.Confi
payload := emptyOnboardingQRResponse()
payload.Schema = onboardingSchemaVersion
payload.InstanceURL = strings.TrimSpace(r.resolvePublicURL(req))
instanceURL, instanceURLDiagnostic := normalizeOnboardingInstanceURL(r.resolvePublicURL(req))
payload.InstanceURL = instanceURL
payload.InstanceID = r.currentRelayInstanceID()
payload.Relay = onboardingRelayDetails{
Enabled: relayCfg.Enabled,
@ -299,6 +300,9 @@ func (r *Router) buildOnboardingPayload(req *http.Request, relayCfg *relay.Confi
payload.AuthToken = authToken
diagnostics := make([]onboardingDiagnostic, 0, 4)
if instanceURLDiagnostic != nil {
diagnostics = append(diagnostics, *instanceURLDiagnostic)
}
if !payload.Relay.Enabled {
diagnostics = append(diagnostics, onboardingDiagnostic{
Code: "relay_disabled",
@ -329,6 +333,27 @@ func (r *Router) buildOnboardingPayload(req *http.Request, relayCfg *relay.Confi
return payload, diagnostics
}
func normalizeOnboardingInstanceURL(raw string) (string, *onboardingDiagnostic) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
parsed, err := url.Parse(raw)
if err == nil && strings.EqualFold(parsed.Scheme, "https") && strings.TrimSpace(parsed.Host) != "" {
return raw, nil
}
return "", &onboardingDiagnostic{
Code: "instance_url_not_https",
Severity: "warning",
Field: "instance_url",
Expected: "https://...",
Received: raw,
Message: "Pulse web link is not included in this pairing code because the resolved Pulse URL is not HTTPS. Pairing can continue, but opening Pulse web from the phone requires an HTTPS Pulse URL.",
}
}
func writeOnboardingNotReady(w http.ResponseWriter, diagnostics []onboardingDiagnostic) {
response := onboardingNotReadyResponse{
Code: onboardingNotReadyCode,
@ -456,7 +481,9 @@ func hasOnboardingError(diagnostics []onboardingDiagnostic) bool {
func buildOnboardingDeepLink(payload onboardingQRResponse) string {
query := url.Values{}
query.Set("schema", payload.Schema)
query.Set("instance_url", payload.InstanceURL)
if payload.InstanceURL != "" {
query.Set("instance_url", payload.InstanceURL)
}
if payload.InstanceID != "" {
query.Set("instance_id", payload.InstanceID)
}

View file

@ -56,6 +56,45 @@ func TestOnboardingQRPayloadStructure(t *testing.T) {
}
}
func TestOnboardingQROmitsNonHTTPSInstanceURL(t *testing.T) {
router, rawToken, _ := newOnboardingContractRouter(t)
router.config.PublicURL = ""
router.config.AgentConnectURL = ""
req := httptest.NewRequest(http.MethodGet, "http://pulse.local.test/api/onboarding/qr", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.handleGetOnboardingQR(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(body), &raw); err != nil {
t.Fatalf("decode raw QR response: %v", err)
}
if _, ok := raw["instance_url"]; ok {
t.Fatalf("expected unsafe top-level instance_url to be omitted from QR response, got %s", body)
}
var payload onboardingQRResponse
if err := json.NewDecoder(strings.NewReader(body)).Decode(&payload); err != nil {
t.Fatalf("decode QR response: %v", err)
}
if payload.InstanceURL != "" {
t.Fatalf("expected empty instance_url, got %q", payload.InstanceURL)
}
if strings.Contains(payload.DeepLink, "instance_url") {
t.Fatalf("expected unsafe instance_url to be omitted from deep link, got %q", payload.DeepLink)
}
if !diagnosticCodePresent(payload.Diagnostics, "instance_url_not_https") {
t.Fatalf("expected instance_url_not_https diagnostic, got %#v", payload.Diagnostics)
}
}
func TestOnboardingQRRejectsIncompleteRelayRegistration(t *testing.T) {
router, rawToken, _ := newOnboardingContractRouter(t)
router.relayClient = nil

View file

@ -2915,11 +2915,11 @@ class SubsystemLookupTest(unittest.TestCase):
api_match["matched_contract_references"],
[
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1172,
"heading_line": 137,
}
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1176,
"heading_line": 137,
}
],
)