mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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
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:
parent
e1cddca2d1
commit
57139c65c5
10 changed files with 156 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue