Skip to content
20 changes: 20 additions & 0 deletions .reports/embedded-react-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1773,6 +1773,10 @@ declare namespace ContractorManagement {
ContractorTab,
PaymentFlow,
PaymentFlowProps,
CreatePaymentFlow,
CreatePaymentFlowProps,
ViewHistoryFlow,
ViewHistoryFlowProps,
PaymentsList,
PaymentsListProps,
CreatePayment,
Expand Down Expand Up @@ -2072,6 +2076,14 @@ type CreatableTimeOffPolicyType = Extract<PolicyType, 'sick' | 'vacation'>;
// @public
function CreatePayment(props: CreatePaymentProps): JSX;

// @alpha
function CreatePaymentFlow(props: CreatePaymentFlowProps): JSX;

// @alpha
interface CreatePaymentFlowProps extends BaseComponentInterface<never> {
companyId: string;
}

// @public
interface CreatePaymentProps extends BaseComponentInterface<'Contractor.Payments.CreatePayment'> {
companyId: string;
Expand Down Expand Up @@ -6204,6 +6216,14 @@ export type UseWorkAddressFormResult = HookLoadingResult | UseWorkAddressFormRea
// @public
export type ValidationMessages<TErrorCode extends string, TOptionalErrorCode extends string = never> = Record<TErrorCode, string> & Partial<Record<TOptionalErrorCode, string>>;

// @alpha
function ViewHistoryFlow(props: ViewHistoryFlowProps): JSX;

// @alpha
interface ViewHistoryFlowProps extends BaseComponentInterface<never> {
paymentId: string;
}

// @public
function ViewHolidayEmployees(props: ViewHolidayEmployeesProps): JSX;

Expand Down
19 changes: 15 additions & 4 deletions docs/guides/endpoint-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -2300,16 +2300,27 @@
"EmployeeOnboarding.OnboardingFlow"
]
},
"ContractorManagement.PaymentFlow": {
"ContractorManagement.CreatePaymentFlow": {
"blocks": [
"ContractorManagement.CreatePayment",
"ContractorManagement.PaymentHistory",
"ContractorManagement.PaymentStatement",
"ContractorManagement.PaymentSummary",
"ContractorManagement.PaymentSummary"
]
},
"ContractorManagement.PaymentFlow": {
"blocks": [
"ContractorManagement.CreatePaymentFlow",
"ContractorManagement.PaymentsList",
"ContractorManagement.ViewHistoryFlow",
"InformationRequests.InformationRequestsFlow"
]
},
"ContractorManagement.ViewHistoryFlow": {
"blocks": [
"ContractorManagement.CreatePayment",
"ContractorManagement.PaymentHistory",
"ContractorManagement.PaymentStatement"
]
},
"ContractorOnboarding.OnboardingFlow": {
"blocks": [
"ContractorOnboarding.Address",
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/endpoint-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,9 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json'

| Flow | Blocks included |
| --- | --- |
| **ContractorManagement.PaymentFlow** | ContractorManagement.CreatePayment, ContractorManagement.PaymentHistory, ContractorManagement.PaymentStatement, ContractorManagement.PaymentSummary, ContractorManagement.PaymentsList, InformationRequests.InformationRequestsFlow |
| **ContractorManagement.CreatePaymentFlow** | ContractorManagement.CreatePayment, ContractorManagement.PaymentSummary |
| **ContractorManagement.PaymentFlow** | ContractorManagement.CreatePaymentFlow, ContractorManagement.PaymentsList, ContractorManagement.ViewHistoryFlow, InformationRequests.InformationRequestsFlow |
| **ContractorManagement.ViewHistoryFlow** | ContractorManagement.CreatePayment, ContractorManagement.PaymentHistory, ContractorManagement.PaymentStatement |
| **ContractorOnboarding.OnboardingFlow** | ContractorOnboarding.Address, ContractorOnboarding.ContractorList, ContractorOnboarding.ContractorProfile, ContractorOnboarding.ContractorSubmit, ContractorOnboarding.NewHireReport, ContractorOnboarding.PaymentMethod |
| **ContractorOnboarding.SelfOnboardingFlow** | ContractorOnboarding.Address, ContractorOnboarding.ContractorProfile, ContractorOnboarding.DocumentSigner, ContractorOnboarding.Landing, ContractorOnboarding.OnboardingSummary, ContractorOnboarding.PaymentMethod |

Expand Down
2 changes: 1 addition & 1 deletion sdk-app/scripts/analyze-component-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { Project, SyntaxKind, type Type } from 'ts-morph'
const ROOT = resolve(import.meta.dirname, '../..')
const OUTPUT = resolve(import.meta.dirname, '../src/generated-registry-data.ts')

const ENTITY_ID_PATTERN = /^(company|employee|contractor|payroll|request)Id$/
const ENTITY_ID_PATTERN = /^(company|employee|contractor|payroll|request|payment)Id$/

const NAMESPACES: Record<string, string[]> = {
CompanyOnboarding: ['src/components/Company/exports/companyOnboarding.ts'],
Expand Down
59 changes: 59 additions & 0 deletions sdk-app/scripts/demo-provisioner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,51 @@
demoType: string
}

/**
* Records a past-dated "Historical Payment" for the given contractor so freshly
* provisioned demos have at least one contractor payment group to inspect
* (e.g. via ContractorManagement.ViewHistoryFlow). Historical payments are
* record-only -- no funding or ACH lead time -- so they post synchronously.
*/
async function seedHistoricalContractorPayment(
proxyBase: string,
companyId: string,
contractorId: string,
): Promise<string> {
const contractorRes = await fetch(`${proxyBase}/v1/contractors/${contractorId}`, {

Check failure on line 72 in sdk-app/scripts/demo-provisioner.ts

View check run for this annotation

BoostSecurity.io AI / boostsecurity - boostsecurityio/semgrep

sdk-app/scripts/demo-provisioner.ts#L72

CWE-918: Server-Side Request Forgery (SSRF) The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CWE-918: Server-Side Request Forgery (SSRF)
Original Rule ID: rules_lgpl_javascript_ssrf_rule-node-ssrf

Details

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.

This rule detected user-controlled URLs being passed to Node.js HTTP client
libraries including axios.get(), axios.post(), fetch(), http.get(),
http.request(), needle(), request(), urllib.request(),
superagent.get(), bent(), got.get(), net.connect(), and
socket.io-client.io(). When user input controls the destination URL of HTTP
requests without validation, Server-Side Request Forgery (SSRF) vulnerabilities
arise. SSRF allows attackers to force the server to make requests to internal
systems, cloud metadata endpoints (such as 169.254.169.254), or other
unauthorized destinations. This can expose internal APIs, databases,
administrative panels, or enable network scanning and pivoting attacks that
bypass firewall rules and network segmentation.
 📘 Learn More

AI Remediation

The fix adds an origin-allowlist re-validation (validateHost) at the start of seedHistoricalContractorPayment before any fetch calls, ensuring the request destination is confined to the pre-approved gws-flows origins and cannot be redirected toward internal systems or cloud metadata endpoints. This closes the SSRF (CWE-918) gap where proxyBase was consumed as an HTTP client URL without validation at the request site.

Suggested change
const contractorRes = await fetch(`${proxyBase}/v1/contractors/${contractorId}`, {
// Re-validate the origin of the proxy base against the allowlist before
// issuing requests to guard against SSRF (CWE-918). proxyBase is derived
// from a validated host plus a flow token, but validating here ensures the
// request destination cannot be redirected to an internal/metadata endpoint.
validateHost(proxyBase)

signal: AbortSignal.timeout(10000),
})
if (!contractorRes.ok) return ''
const contractor = (await contractorRes.json()) as { wage_type?: string }

const checkDate = new Date()
checkDate.setDate(checkDate.getDate() - 14)

const paymentRes = await fetch(
`${proxyBase}/v1/companies/${companyId}/contractor_payment_groups`,

Check failure on line 82 in sdk-app/scripts/demo-provisioner.ts

View check run for this annotation

BoostSecurity.io AI / boostsecurity - boostsecurityio/semgrep

sdk-app/scripts/demo-provisioner.ts#L82

CWE-918: Server-Side Request Forgery (SSRF) The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CWE-918: Server-Side Request Forgery (SSRF)
Original Rule ID: rules_lgpl_javascript_ssrf_rule-node-ssrf

Details

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.

This rule detected user-controlled URLs being passed to Node.js HTTP client
libraries including axios.get(), axios.post(), fetch(), http.get(),
http.request(), needle(), request(), urllib.request(),
superagent.get(), bent(), got.get(), net.connect(), and
socket.io-client.io(). When user input controls the destination URL of HTTP
requests without validation, Server-Side Request Forgery (SSRF) vulnerabilities
arise. SSRF allows attackers to force the server to make requests to internal
systems, cloud metadata endpoints (such as 169.254.169.254), or other
unauthorized destinations. This can expose internal APIs, databases,
administrative panels, or enable network scanning and pivoting attacks that
bypass firewall rules and network segmentation.
 📘 Learn More

AI Remediation

The base host of each fetch URL (proxyBase) already flows from the allowlist-validated safeHost, but the interpolated companyId/contractorId path segments were unvalidated, allowing potential URL/path manipulation that could redirect requests to unintended destinations (SSRF). The fix enforces a strict identifier allowlist (^[a-zA-Z0-9_-]+$) and applies encodeURIComponent so these values cannot alter the request target. This constrains the outbound request destination to the validated proxy base with well-formed path segments.

At line 69, do the following changes:

   companyId: string,
   contractorId: string,
 ): Promise<string> {
-  const contractorRes = await fetch(`${proxyBase}/v1/contractors/${contractorId}`, {
-    signal: AbortSignal.timeout(10000),
-  })
+  // Guard against path/URL injection: IDs are interpolated into request URLs,
+  // so restrict them to opaque identifier characters before use.
+  const ID_PATTERN = /^[a-zA-Z0-9_-]+$/
+  if (!ID_PATTERN.test(companyId) || !ID_PATTERN.test(contractorId)) {
+    throw new Error('Invalid company or contractor ID')
+  }
+
+  const contractorRes = await fetch(
+    `${proxyBase}/v1/contractors/${encodeURIComponent(contractorId)}`,
+    {
+      signal: AbortSignal.timeout(10000),
+    },
+  )
   if (!contractorRes.ok) return ''
   const contractor = (await contractorRes.json()) as { wage_type?: string }
 

At line 79, do the following changes:

   checkDate.setDate(checkDate.getDate() - 14)
 
   const paymentRes = await fetch(
-    `${proxyBase}/v1/companies/${companyId}/contractor_payment_groups`,
+    `${proxyBase}/v1/companies/${encodeURIComponent(companyId)}/contractor_payment_groups`,
     {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },

{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(10000),
body: JSON.stringify({
check_date: checkDate.toISOString().slice(0, 10),
creation_token: `sdk-app-seed-${companyId}-${contractorId}`,
contractor_payments: [
{
contractor_uuid: contractorId,
payment_method: 'Historical Payment',
...(contractor.wage_type === 'Hourly' ? { hours: '10' } : { wage: '500.00' }),
},
],
}),
},
)
if (!paymentRes.ok) return ''

const created = (await paymentRes.json()) as { contractor_payment_group?: { uuid?: string } }
return created.contractor_payment_group?.uuid || ''
}

export async function createDemoAndProvision(
gwsFlowsHost: string,
demoType: string,
Expand Down Expand Up @@ -150,6 +195,20 @@
if (companyId) {
onProgress?.('Fetching entity IDs...')
entities = await fetchEntityIds(proxyBase, companyId)

if (entities.contractorId && !entities.paymentId) {
onProgress?.('Seeding a historical contractor payment...')
try {
const paymentId = await seedHistoricalContractorPayment(
proxyBase,
companyId,
entities.contractorId,
)
if (paymentId) entities.paymentId = paymentId
} catch {
// Best-effort seed; demo creation should not fail if this errors.
}
}
}

return { flowToken, companyId, entities, demoType }
Expand Down
3 changes: 3 additions & 0 deletions sdk-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function entitiesFromManualConfig(config: ManualConfig): EntityIds {
payrollId: config.payrollId,
formId: config.formId,
requestId: config.requestId,
paymentId: config.paymentId,
}
}

Expand All @@ -87,6 +88,7 @@ export function App() {
manual.config.payrollId,
manual.config.formId,
manual.config.requestId,
manual.config.paymentId,
],
)
const entityCatalog = useEntityCatalog(isManual ? '' : entities.companyId)
Expand Down Expand Up @@ -212,6 +214,7 @@ export function App() {
payrollId: result.entities.payrollId || '',
formId: result.entities.formId || '',
requestId: '',
paymentId: result.entities.paymentId || '',
})
window.location.reload()
}
Expand Down
29 changes: 28 additions & 1 deletion sdk-app/src/DemoSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const MANUAL_FIELDS: { key: keyof ManualConfig; label: string; required?: boolea
{ key: 'payrollId', label: 'Payroll ID' },
{ key: 'formId', label: 'Form ID' },
{ key: 'requestId', label: 'Request ID' },
{ key: 'paymentId', label: 'Payment ID' },
]

interface EntityComboboxProps {
Expand Down Expand Up @@ -391,7 +392,8 @@ export function DemoSettingsPanel({
entities.contractorId !== confirmedSnapshot.current.contractorId ||
entities.payrollId !== confirmedSnapshot.current.payrollId ||
entities.requestId !== confirmedSnapshot.current.requestId ||
entities.formId !== confirmedSnapshot.current.formId
entities.formId !== confirmedSnapshot.current.formId ||
entities.paymentId !== confirmedSnapshot.current.paymentId

const displayEnv = env === 'localzp' ? 'local' : env

Expand Down Expand Up @@ -775,6 +777,31 @@ export function DemoSettingsPanel({
}
/>

<EntityCombobox
label="Payment"
value={entities.paymentId}
options={entityCatalog.payments}
isLoading={entityCatalog.isLoading}
placeholder="Search or paste a payment id..."
useFallback={!isFlowTokenMode}
onChange={value => {
onUpdateEntity('paymentId', value)
}}
trailing={
<>
<CopyIdButton value={entities.paymentId} ariaLabel="Copy payment ID" />
<InspectIdButton
label="Payment"
request={buildEntityInspectRequest(
'paymentId',
entities.paymentId,
entities.companyId,
)}
/>
</>
}
/>

<EntityCombobox
label="Request"
value={entities.requestId}
Expand Down
3 changes: 3 additions & 0 deletions sdk-app/src/entity-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const ENTITY_ENDPOINTS = {
contractorId: 'contractors',
payrollId: 'payrolls',
formId: 'forms',
paymentId: 'contractor_payment_groups',
} as const

type EntityIdKey = keyof typeof ENTITY_ENDPOINTS
Expand Down Expand Up @@ -86,6 +87,8 @@ export function buildEntityInspectRequest(
: null
case 'formId':
return { kind: 'show', url: `/api/v1/forms/${id}` }
case 'paymentId':
return { kind: 'show', url: `/api/v1/contractor_payment_groups/${id}` }
case 'requestId':
return companyId
? {
Expand Down
12 changes: 12 additions & 0 deletions sdk-app/src/entityFormatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export interface RawInformationRequest {
blocking_payroll?: boolean
}

export interface RawContractorPaymentGroup {
uuid?: string
check_date?: string
status?: string
totals?: { amount?: string }
}

export interface EntityOption {
value: string
primary: string
Expand Down Expand Up @@ -65,6 +72,11 @@ export function formatForm(form: RawForm): string {
return form.title || form.name || 'Form'
}

export function formatPaymentGroup(group: RawContractorPaymentGroup): string {
const date = group.check_date ? `Check date ${group.check_date}` : 'Payment'
return group.totals?.amount ? `${date} · $${group.totals.amount}` : date
}

export function formatInformationRequestType(type: string | null | undefined): string {
if (!type) return 'Information request'
return type
Expand Down
6 changes: 4 additions & 2 deletions sdk-app/src/generated-registry-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ export const ENTITY_REQUIREMENTS: Record<string, string[]> = {
'CompanyOnboarding.StateTaxes': ['companyId'],
'CompanyOnboarding.StateTaxesForm': ['companyId'],
'CompanyOnboarding.StateTaxesList': ['companyId'],
'ContractorManagement.ContractorList': ['companyId'],
'ContractorManagement.CreatePayment': ['companyId'],
'ContractorManagement.CreatePaymentFlow': ['companyId'],
'ContractorManagement.PaymentFlow': ['companyId'],
'ContractorManagement.PaymentHistory': ['companyId'],
'ContractorManagement.PaymentHistory': ['paymentId'],
'ContractorManagement.PaymentStatement': ['companyId'],
'ContractorManagement.PaymentSummary': ['companyId'],
'ContractorManagement.PaymentsList': ['companyId'],
'ContractorManagement.ViewHistoryFlow': ['paymentId'],
'ContractorOnboarding.Address': ['contractorId'],
'ContractorOnboarding.ContractorList': ['companyId'],
'ContractorOnboarding.ContractorProfile': ['companyId'],
Expand Down Expand Up @@ -139,7 +142,6 @@ export const ENTITY_REQUIREMENTS: Record<string, string[]> = {
export const ADDITIONAL_REQUIRED_PROPS: Record<string, string[]> = {
'CompanyOnboarding.SignatureForm': ['formId'],
'CompanyOnboarding.StateTaxesForm': ['state'],
'ContractorManagement.PaymentHistory': ['paymentId'],
'ContractorManagement.PaymentStatement': ['paymentGroupId', 'contractorUuid'],
'ContractorManagement.PaymentSummary': ['paymentGroupId'],
'ContractorOnboarding.SignatureForm': ['documentUuid'],
Expand Down
7 changes: 6 additions & 1 deletion sdk-app/src/useEntities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function getEnvDefaults(): EntityIds {
payrollId: import.meta.env.VITE_PAYROLL_ID || '',
requestId: import.meta.env.VITE_REQUEST_ID || '',
formId: import.meta.env.VITE_FORM_ID || '',
paymentId: import.meta.env.VITE_PAYMENT_ID || '',
}
}

Expand Down Expand Up @@ -46,7 +47,7 @@ function saveToStorage(ids: EntityIds) {
}

function hasMissingEntities(ids: EntityIds): boolean {
return !ids.employeeId || !ids.contractorId || !ids.payrollId || !ids.formId
return !ids.employeeId || !ids.contractorId || !ids.payrollId || !ids.formId || !ids.paymentId
}

export function useEntities() {
Expand All @@ -60,6 +61,7 @@ export function useEntities() {
payrollId: stored.payrollId || defaults.payrollId,
requestId: stored.requestId || defaults.requestId,
formId: stored.formId || defaults.formId,
paymentId: stored.paymentId || defaults.paymentId,
}
})

Expand Down Expand Up @@ -98,6 +100,9 @@ export function useEntities() {
? data.payrollId || prev.payrollId
: prev.payrollId || data.payrollId || '',
formId: overwrite ? data.formId || prev.formId : prev.formId || data.formId || '',
paymentId: overwrite
? data.paymentId || prev.paymentId
: prev.paymentId || data.paymentId || '',
}))
}
} catch {
Expand Down
Loading
Loading