Unsafe Consumption of Third-Party APIs

OWASP API10 — you validate what users send you and trust whatever a vendor returns.

API10 exists because of an asymmetry in how teams think. Data from a user is treated as hostile. Data from a payment provider, a CRM or a partner is treated as fact — despite arriving over the same network, in the same format, and being just as capable of carrying an attack.

Why the trust is misplaced

  • The third party can be compromised, and their response is then attacker-controlled.
  • They can be impersonated — a DNS hijack, an expired certificate check, a redirect you followed.
  • They can change their schema without telling you.
  • They can go down, and your handling of that is a security decision.
  • They can relay attacker input — a user's name, arriving through a partner, is still a user's name.

What to do with a response

  1. Validate the schema as strictly as you validate a browser request. Unknown fields, wrong types and out-of-range values are rejected.
  2. Bound the size before parsing.
  3. Never render it unescaped, never concatenate it into a query, never write it to a path.
  4. Do not follow redirects blindly — a redirect from a compromised vendor is an SSRF into your network.
  5. Verify TLS properly, and consider pinning for high-value integrations.

Decide the failure mode in advance

When a dependency is unavailable, is the answer allow or deny? For a fraud check, a licence check or an authorization service the answer is deny. For an enrichment lookup it can be continue without it. Write the decision down per integration rather than leaving it to whoever writes the catch block during an incident.

Webhooks are the inbound half

A webhook is a third party POSTing to you, and anything can POST to a URL. Verify the signature before parsing, and treat the contents as a notification to go and confirm rather than as a fact.

Example

Example · javascript
// ❌ The vendor said so, so it must be true
const account = await (await fetch(`https://partner.com/accounts/${id}`)).json();
element.innerHTML = account.displayName;              // XSS via a partner
await db.raw(`UPDATE x SET ref='${account.ref}'`);    // injection via a partner
if (account.status === 'active') grantAccess();       // trusted without limit

// ✅ Their response is input
const account = partnerSchema.parse(await fetchBounded(url));
element.textContent = account.displayName;
await db('x').update({ ref: account.ref });
if (account.status === 'active') grantAccess();       // now a validated enum

When to use it

  • A compromised partner API returns a display name containing script, which is stored and rendered until schema validation is added.
  • A vendor's redirect is followed into the internal network, turning their outage into an SSRF against the cloud metadata service.
  • A fraud-check timeout silently approves transactions until the failure mode is changed from fail-open to fail-closed.

More examples

A client that treats the response as hostile

Alerting on schema violations rather than silently failing is the useful half — it is the earliest signal of both a vendor breaking change and a vendor compromise.

Example · javascript
import { z } from 'zod';

// Validate as strictly as a browser request. Ranges matter: a compromised
// partner returning a balance of 10^15 should fail, not propagate.
const partnerAccountSchema = z.object({
  id: z.string().regex(/^acc_[a-zA-Z0-9]{16}$/),
  displayName: z.string().min(1).max(200),
  status: z.enum(['active', 'suspended', 'closed']),
  balanceCents: z.number().int().min(0).max(1_000_000_000),
  currency: z.enum(['GBP', 'USD', 'EUR']),
  updatedAt: z.string().datetime(),
  metadata: z.record(z.string().max(500)).optional(),
}).strict();                       // ← unknown fields are rejected

export async function fetchPartnerAccount(accountId) {
  if (!/^acc_[a-zA-Z0-9]{16}$/.test(accountId)) {
    throw new BadRequestError('invalid_account_id');    // do not build a URL from it
  }

  const res = await fetch(`https://partner.example.com/accounts/${accountId}`, {
    headers: {
      Authorization: `Bearer ${PARTNER_TOKEN}`,
      Accept: 'application/json',
    },
    signal: AbortSignal.timeout(5000),      // never hang on someone else
    redirect: 'error',                      // a redirect could point anywhere
  });

  if (res.status === 404) return null;
  if (!res.ok) throw new UpstreamError(`partner_status_${res.status}`);

  // Content type: a compromised partner may return HTML
  const type = res.headers.get('content-type') ?? '';
  if (!type.includes('application/json')) {
    throw new UpstreamError('unexpected_content_type');
  }

  // Bound the body as it streams — Content-Length is a claim
  const chunks = [];
  let total = 0;
  for await (const chunk of res.body) {
    total += chunk.length;
    if (total > 1_000_000) throw new UpstreamError('response_too_large');
    chunks.push(chunk);
  }

  let raw;
  try {
    raw = JSON.parse(Buffer.concat(chunks).toString('utf8'));
  } catch {
    throw new UpstreamError('invalid_json');
  }

  const parsed = partnerAccountSchema.safeParse(raw);
  if (!parsed.success) {
    // A schema failure is worth alerting on: it is either their breaking
    // change or their compromise, and both need a human.
    logger.error({ issues: parsed.error.issues, accountId },
      'partner response failed validation');
    metrics.increment('partner.schema_violation');
    throw new UpstreamError('invalid_upstream_response');
  }

  return parsed.data;
}

// And downstream, still treat the values as untrusted content:
//   ✅ element.textContent = account.displayName
//   ✅ db('x').update({ ref: account.ref })
//   ✅ path.join(BASE, sanitiseFilename(account.id))

Failure modes, decided in advance

Throwing on an unknown integration name forces the table to stay complete — a new dependency cannot be added without someone choosing its failure mode.

Example · javascript
// Write the table down. Leaving it to a catch block during an incident is how
// bypasses get introduced.

const INTEGRATIONS = {
  fraudCheck: {
    onFailure: 'deny',
    reason: 'a bypassable control — failing open lets an attacker degrade it',
    timeoutMs: 2000,
  },
  authorizationService: {
    onFailure: 'deny',
    reason: 'authorization, obviously',
    timeoutMs: 1000,
  },
  paymentProvider: {
    onFailure: 'deny',
    reason: 'never assume a payment succeeded',
    timeoutMs: 10_000,
  },
  rateLimitStore: {
    onFailure: 'degrade',
    reason: 'a control, but total unavailability is worse than approximation',
    timeoutMs: 200,
  },
  featureFlags: {
    onFailure: 'last-known',
    reason: 'not a security control; stale values are acceptable',
    timeoutMs: 500,
  },
  companyEnrichment: {
    onFailure: 'allow',
    reason: 'cosmetic',
    timeoutMs: 1500,
  },
};

export async function callIntegration(name, fn, fallback) {
  const spec = INTEGRATIONS[name];
  if (!spec) throw new Error(`Unknown integration: ${name} — add it to the table`);

  try {
    return await withTimeout(fn(), spec.timeoutMs);
  } catch (err) {
    metrics.increment('integration.failure', { name, mode: spec.onFailure });
    logger.error({ err, integration: name }, 'integration failed');

    switch (spec.onFailure) {
      case 'deny':
        throw new ServiceUnavailableError(
          'We cannot process this right now. Please try again shortly.');
      case 'degrade':
        return fallback?.() ?? null;
      case 'last-known':
        return await lastKnownValue(name);
      case 'allow':
        return null;
    }
  }
}

// Usage — the failure mode is declared, not improvised
const risk = await callIntegration('fraudCheck', () => fraud.assess(order));
const company = await callIntegration('companyEnrichment', () => clearbit.lookup(email));

// And a test that pins each one, because a refactor will quietly change it:
it('fails closed when the fraud service is down', async () => {
  fraudService.mockRejection(new Error('timeout'));
  await expect(placeOrder(order)).rejects.toThrow(ServiceUnavailableError);
  expect(await ordersCreated()).toBe(0);        // and nothing was created
});

Certificate pinning for high-value integrations

The backup pin is not optional — pinning a single key converts the vendor's routine key rotation into an outage you cannot fix without a deploy.

Example · javascript
// Standard TLS trusts every CA in the store. Pinning narrows that to the
// specific key you expect, which defends against a mis-issued certificate.
import https from 'node:https';
import { createHash } from 'crypto';

// Pin the SPKI hash, not the certificate — the pin then survives renewal as
// long as the key is reused.
const PINS = {
  'payments.example.com': [
    'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',   // current
    'sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=',   // backup — REQUIRED
  ],
};

function spkiFingerprint(cert) {
  return 'sha256/' + createHash('sha256').update(cert.pubkey).digest('base64');
}

const agent = new https.Agent({
  keepAlive: true,
  checkServerIdentity(host, cert) {
    // Standard hostname and chain validation first — never replace it.
    const err = https.globalAgent.options.checkServerIdentity?.(host, cert)
      ?? require('tls').checkServerIdentity(host, cert);
    if (err) return err;

    const pins = PINS[host];
    if (!pins) return undefined;              // not pinned: standard rules apply

    // Check the whole chain, so pinning an intermediate also works.
    let node = cert;
    while (node) {
      if (pins.includes(spkiFingerprint(node))) return undefined;
      node = node.issuerCertificate !== node ? node.issuerCertificate : null;
    }

    metrics.increment('tls.pin_failure', { host });
    alertSecurityChannel(`Certificate pin failure for ${host}`);
    return new Error(`Certificate pin validation failed for ${host}`);
  },
});

await fetch('https://payments.example.com/charges', { agent, ... });

// ── The operational risks, stated honestly ───────────────────────────
// 1. A backup pin is MANDATORY. Pinning one key means their unannounced
//    key rotation is a total outage for you.
// 2. Pins expire. Track the vendor's rotation schedule, and monitor the
//    certificate you actually receive so you see a change coming.
// 3. Have a documented emergency path to disable pinning — a config flag,
//    not a code deploy, because you will need it at 3am.
//
// Pin only where the value justifies the operational burden: payments,
// identity providers, and anything moving money. Do not pin everything.

Discussion

  • Be the first to comment on this lesson.