DEV Community

Marco Cheung
Marco Cheung

Posted on

Defensive Programming: The Hidden Dangers of Spread Operators in Request Payloads

The Problem: When Convenience Becomes a Security Risk

The JavaScript spread operator (...) is one of the most convenient features in modern JavaScript and TypeScript. It allows us to easily merge objects, clone arrays, and pass multiple arguments to functions. However, when it comes to constructing request payloads—especially for APIs with strict schemas like GraphQL—the spread operator can become a dangerous tool that introduces runtime errors and security vulnerabilities.

The Spread Operator Trap

What Seems Convenient...

Consider this common pattern when building API request payloads:

// ❌ Dangerous: Using spread operator for payload construction
const createUserPayload = {
  ...userFormData,        // Contains: name, email, age, debugInfo, internalFlags
  ...additionalData,      // Contains: preferences, metadata, tempId
  ...featureFlags,        // Contains: enableBeta, debugMode, adminAccess
  operation: 'CREATE_USER'
}

// Send to GraphQL API
const result = await graphqlClient.mutate({
  mutation: CREATE_USER_MUTATION,
  variables: { input: createUserPayload }
})
Enter fullscreen mode Exit fullscreen mode

...Can Break in Production

# GraphQL Schema only accepts these fields:
input CreateUserInput {
  name: String!
  email: String!
  age: Int
  preferences: UserPreferences
  operation: String!
}
Enter fullscreen mode Exit fullscreen mode

Runtime Error:

GraphQL Error: Field "debugInfo" is not defined by type "CreateUserInput"
GraphQL Error: Field "internalFlags" is not defined by type "CreateUserInput"  
GraphQL Error: Field "tempId" is not defined by type "CreateUserInput"
GraphQL Error: Field "debugMode" is not defined by type "CreateUserInput"
GraphQL Error: Field "adminAccess" is not defined by type "CreateUserInput"
Enter fullscreen mode Exit fullscreen mode

Why TypeScript Can't Save You

Compile-Time Limitations

TypeScript's type checking has limitations when it comes to spread operators and dynamic object construction:

interface UserFormData {
  name: string
  email: string
  age: number
  // TypeScript doesn't know about these at compile time:
  [key: string]: any  // Index signature allows anything
}

interface CreateUserInput {
  name: string
  email: string
  age?: number
  operation: string
}

// ❌ TypeScript allows this, but it's dangerous
const payload: CreateUserInput = {
  ...userFormData,  // May contain extra fields
  operation: 'CREATE_USER'
}
// No compile-time error, but runtime failure guaranteed
Enter fullscreen mode Exit fullscreen mode

The Index Signature Problem

// ❌ Common anti-pattern that defeats TypeScript's safety
interface FormData {
  [key: string]: any  // This disables type checking
}

// ❌ Or using 'any' type
const buildPayload = (data: any) => ({
  ...data,
  timestamp: Date.now()
})
Enter fullscreen mode Exit fullscreen mode

Real-World Attack Vectors

1. Debug Information Leakage

// ❌ Development debug info accidentally sent to production
const orderPayload = {
  ...orderData,
  ...debugInfo,  // Contains: userId, sessionId, internalNotes
  ...{
    // Development-only fields that shouldn't reach production
    debugTimestamp: Date.now(),
    developerNotes: 'Testing checkout flow',
    internalCustomerId: 'dev_12345'
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Privilege Escalation

// ❌ User input accidentally includes admin fields
const userInput = {
  name: 'John Doe',
  email: 'john@example.com',
  // Malicious user adds these fields:
  isAdmin: true,
  permissions: ['DELETE_USERS', 'ACCESS_ADMIN_PANEL'],
  role: 'SUPER_ADMIN'
}

const updateUserPayload = {
  ...userInput,  // Blindly spreads potentially malicious data
  updatedAt: new Date()
}
Enter fullscreen mode Exit fullscreen mode

3. Schema Evolution Breakage

// ❌ Old code breaks when API schema changes
const legacyPayload = {
  ...oldUserData,     // Contains deprecated fields
  ...newFeatureData,  // Contains fields not yet in schema
  ...experimentalData // Contains A/B test fields
}

// API schema removed 'legacyField' and doesn't recognize 'experimentalField'
// Result: Runtime errors in production
Enter fullscreen mode Exit fullscreen mode

Defensive Programming Solutions

1. Explicit Field Selection with Ramda's pick

import { pick } from 'ramda'

// ✅ Safe: Explicitly define allowed fields
const ALLOWED_USER_FIELDS = [
  'name',
  'email', 
  'age',
  'preferences'
] as const

const createUserPayload = {
  ...pick(ALLOWED_USER_FIELDS, userFormData),
  operation: 'CREATE_USER'
}

// Only specified fields are included, everything else is filtered out
Enter fullscreen mode Exit fullscreen mode

2. Schema-Based Validation

// ✅ Define strict interfaces without index signatures
interface CreateUserInput {
  readonly name: string
  readonly email: string
  readonly age?: number
  readonly preferences?: UserPreferences
  readonly operation: 'CREATE_USER'
}

// ✅ Builder pattern for safe construction
class UserPayloadBuilder {
  private payload: Partial<CreateUserInput> = {}

  setName(name: string): this {
    this.payload.name = name
    return this
  }

  setEmail(email: string): this {
    this.payload.email = email
    return this
  }

  setAge(age: number): this {
    this.payload.age = age
    return this
  }

  build(): CreateUserInput {
    if (!this.payload.name || !this.payload.email) {
      throw new Error('Name and email are required')
    }

    return {
      name: this.payload.name,
      email: this.payload.email,
      age: this.payload.age,
      preferences: this.payload.preferences,
      operation: 'CREATE_USER'
    }
  }
}

// Usage
const payload = new UserPayloadBuilder()
  .setName(userFormData.name)
  .setEmail(userFormData.email)
  .setAge(userFormData.age)
  .build()
Enter fullscreen mode Exit fullscreen mode

3. Functional Approach with Type Guards

// ✅ Type-safe field extraction
const extractUserFields = (data: unknown): CreateUserInput => {
  if (!isValidUserData(data)) {
    throw new Error('Invalid user data')
  }

  return {
    name: data.name,
    email: data.email,
    age: data.age,
    preferences: data.preferences,
    operation: 'CREATE_USER'
  }
}

// Type guard function
function isValidUserData(data: any): data is {
  name: string
  email: string
  age?: number
  preferences?: UserPreferences
} {
  return (
    typeof data === 'object' &&
    typeof data.name === 'string' &&
    typeof data.email === 'string' &&
    (data.age === undefined || typeof data.age === 'number')
  )
}
Enter fullscreen mode Exit fullscreen mode

4. Whitelist-Based Object Construction

import { pick, omit } from 'ramda'

// ✅ Multiple layers of defense
const buildSafePayload = (rawData: Record<string, any>) => {
  // Layer 1: Remove known dangerous fields
  const withoutDangerousFields = omit([
    'debugInfo',
    'internalFlags', 
    'adminAccess',
    'isAdmin',
    'permissions',
    'sessionId',
    'authToken'
  ], rawData)

  // Layer 2: Only include allowed fields
  const allowedFields = pick([
    'name',
    'email',
    'age',
    'preferences'
  ], withoutDangerousFields)

  // Layer 3: Explicit construction with validation
  return {
    name: String(allowedFields.name || '').trim(),
    email: String(allowedFields.email || '').toLowerCase().trim(),
    age: typeof allowedFields.age === 'number' ? allowedFields.age : undefined,
    preferences: allowedFields.preferences || {},
    operation: 'CREATE_USER' as const
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Defensive Patterns

1. Runtime Schema Validation

Please refer to my another article
https://dev.to/marco_cheung_/client-side-graphql-variable-sanitization-preventing-runtime-errors-before-they-happen-kjn

Performance Considerations

Spread Operator Performance Impact

// ❌ Performance issues with large objects
const hugeObject = { /* 10,000 properties */ }
const anotherHugeObject = { /* 10,000 properties */ }

// Creates new object with 20,000 properties - expensive!
const combined = {
  ...hugeObject,
  ...anotherHugeObject,
  newField: 'value'
}

// ✅ Better: Only pick what you need
const optimized = {
  ...pick(['field1', 'field2', 'field3'], hugeObject),
  ...pick(['field4', 'field5'], anotherHugeObject),
  newField: 'value'
}
Enter fullscreen mode Exit fullscreen mode

Memory Usage Comparison

// ❌ Memory inefficient
const createPayloads = (users: User[]) => {
  return users.map(user => ({
    ...user,           // Copies all user fields (potentially 50+ fields)
    ...user.profile,   // Copies all profile fields (potentially 30+ fields)  
    ...user.settings,  // Copies all settings (potentially 20+ fields)
    timestamp: Date.now()
  }))
}

// ✅ Memory efficient
const createOptimizedPayloads = (users: User[]) => {
  return users.map(user => ({
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.profile?.avatar,
    theme: user.settings?.theme,
    timestamp: Date.now()
  }))
}
Enter fullscreen mode Exit fullscreen mode

Testing Defensive Patterns

1. Property-Based Testing

import fc from 'fast-check'

// ✅ Test with random malicious inputs
describe('Payload sanitization', () => {
  it('should handle arbitrary malicious input', () => {
    fc.assert(
      fc.property(
        fc.record({
          // Valid fields
          name: fc.string(),
          email: fc.emailAddress(),
          // Malicious fields
          isAdmin: fc.boolean(),
          deleteAllUsers: fc.boolean(),
          __proto__: fc.anything(),
          constructor: fc.anything()
        }),
        (maliciousInput) => {
          const sanitized = buildSafePayload(maliciousInput)

          // Should only contain allowed fields
          expect(Object.keys(sanitized)).toEqual(['name', 'email', 'operation'])

          // Should not contain dangerous fields
          expect(sanitized).not.toHaveProperty('isAdmin')
          expect(sanitized).not.toHaveProperty('deleteAllUsers')
          expect(sanitized).not.toHaveProperty('__proto__')
        }
      )
    )
  })
})
Enter fullscreen mode Exit fullscreen mode

2. Security-Focused Unit Tests

describe('Security tests', () => {
  it('should prevent prototype pollution', () => {
    const maliciousPayload = {
      name: 'John',
      email: 'john@example.com',
      '__proto__': { isAdmin: true },
      'constructor': { prototype: { isAdmin: true } }
    }

    const sanitized = buildSafePayload(maliciousPayload)

    expect(sanitized.isAdmin).toBeUndefined()
    expect(Object.prototype.isAdmin).toBeUndefined()
  })

  it('should handle deeply nested malicious objects', () => {
    const deepMalicious = {
      name: 'John',
      preferences: {
        theme: 'dark',
        admin: {
          deleteUsers: true,
          accessLevel: 'SUPER_ADMIN'
        }
      }
    }

    const sanitized = buildSafePayload(deepMalicious)

    expect(sanitized.preferences?.admin).toBeUndefined()
  })
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

The spread operator is a powerful tool, but with great power comes great responsibility. When building request payloads for APIs—especially those with strict schemas like GraphQL—defensive programming practices are essential:

Key Takeaways:

  1. Explicit is Better Than Implicit: Use pick() or explicit field selection instead of spreading entire objects
  2. TypeScript Isn't Enough: Runtime validation is crucial for security
  3. Validate at Multiple Layers: Input validation, schema validation, and business logic validation
  4. Test with Malicious Input: Use property-based testing to catch edge cases
  5. Follow Zero Trust: Never trust input data, always validate and sanitize

The Golden Rule:

When in doubt, be explicit. It's better to write a few extra lines of code than to debug a production incident caused by unexpected fields in your API requests.

By following these defensive programming practices, you can build more robust, secure, and maintainable applications that gracefully handle the unexpected—because in software development, the unexpected is the only thing you can truly expect.


Remember: Security is not a feature you add later—it's a mindset you adopt from the beginning.

Top comments (2)

Collapse
 
prime_1 profile image
Roshan Sharma

Great post, Marco
Super clear and really useful on the spread operator pitfalls

Collapse
 
artyprog profile image
ArtyProg

Great, than you :-)