Skip to content
Florian edited this page Feb 17, 2023 · 1 revision

Anti-SSRF solution

SSRF (Server Side Request Forgery) is a vulnerability used by attackers to do network internal requests when an URL is passed as an argument of your functions.

Impact

The impact of this vulnerability is completely related to what's accessible inside your internal network and what's the purpose of your function working on the URL attacker controlled argument.

Let's explain it with a Slite vulnerability discovered during a penetration testing.

Import content process

Slite gives the ability to import content from various format into a note.

To lighten our media upload process, we directly upload files from browser into google cloud bucket.

Authenticated browser asks via GraphQL getFileUploadUrl a signed unique upload URL.

Then it uses it to upload the file and then call importFilesFromUrlList with the read URL as an argument.

The backend then download file from this URL and try to convert it to a note.

Vulnerability

As you may guess, if you specify internal URL into importFilesFromUrlList you'll exploit the SSRF and create a note from internal endpoint.

SSRF exploitation is not trivial as you should know internal mapping

In our case someone could call our internal JobManager and bypass Basic Authentication done by external reverse proxy.

export

Solution

In this specific case, the solution is quite simple as we know the URL should be from our own specific google bucket. We added an allowlist check on importFilesFromUrlList to be sure it's our own bucket and even use google storage API instead of simple HTTP download.

Universal solution ?

OK, we understood what is an SSRF but our solution was very case specific. Most of the time you can't pass an allowlist of known external URL you will accept.

Blocklist

Blocklisting internal URL is fastidious and error prone. Moreover you can't imagine how many bypass techniques there are if you play with string comparison.

IPV6 ? IPV4 ? DNS ? Punycode ? decimal ipv4 ? Pick your poison...

Network level firewalling

As we could see, URL string to network request could be fooled by many techniques.

What's harder to fool then is network level communication itself.

If we could make all our dangerous requests from a single node and firewall it, it should fix the root problem.

Proxy SOCKS

A well known technique to centralize requests in one place is to use a proxy SOCKS.

We picked Dante proxy as it seems to be the most robust and efficient proxy SOCKS5 implementation.

Then we can:

Configure iptables

This is tedious and we all know someone who lock itself outside of the host...

Furthermore, it's really not compatible with kubernetes and micro services contenerized architecture sharing the same host...

Setup this proxy SOCKS on a different isolated network

A trade-off between practicality and security, why not ?

We will need to communicate with this proxy SOCKS using external connection so let's use SOCKS5 authentication.

Wait, what ?

Since the request carries the password in cleartext, this subnegotiation is not recommended for environments where "sniffing" is possible and practical.

Let's wrap it with TLS then !

Use Dante configuration

Dante provide a simple block and allow configuration which will operate at the network level after SOCKS5 negotiation.

This way we can integrate it in our private network, communicate with it but it will prevent further communication to go back into our private network.

Let's use it in our code

At Slite we use NodeJS with Typescript and we use got to perform our HTTP requests.

Eslint

We started to add a custom eslint rule:

https://github.com/sliteteam/eslint-plugin-slite/blob/master/lib/rules/got-ssrf-protection.js

function lookForGotSSRFFunction(node) {
  const [, options] = node.arguments;
  return (
    !!options &&
    !!options.callee &&
    ["gotSSRFProtected", "gotSSRFUnprotected"].includes(options.callee.name)
  );}

function set(cache, context, key, value) {
  if (context.getFilename() !== cache.previousFilename) {
    delete cache.save[cache.previousFilename];
    cache.previousFilename = context.getFilename();
    cache.save[context.getFilename()] = {};
  }
  cache.save[context.getFilename()][key] = value;}

function get(cache, context, key, fallback) {
  if (cache.save[context.getFilename()]) {
    return cache.save[context.getFilename()][key]
      ? cache.save[context.getFilename()][key]
      : fallback;
  }
  return fallback;}

module.exports = {
  meta: {
    fixable: "code",
    type: "problem",
  },

  create(context) {
    const cache = {
      previousFilename: undefined,
      save: {},
    };

    return {
      ImportDeclaration(node) {
        if (
          node.source.type === "Literal" &&
          node.source.value === "got" &&
          node.specifiers[0].type === "ImportDefaultSpecifier"
        ) {
          set(cache, context, "importedGot", node.specifiers[0].local.name);
        }
      },
      CallExpression(node) {
        if (
          node.callee.name === "require" &&
          node.arguments[0].value === "got" &&
          node.parent.id.type === "Identifier"
        ) {
          return set(cache, context, "importedGot", node.parent.id.name);
        }

        const got = get(cache, context, "importedGot", "got");
        if (
          node.callee.name === got ||
          (node.callee.object &&
            node.callee.object.name === got &&
            node.callee.type === "MemberExpression" &&
            !node.callee.property.name.startsWith("mock"))
        ) {
          const expliciteProtection = lookForGotSSRFFunction(node);

          if (!expliciteProtection) {
            context.report({
              node: node.callee,
              message:
                `got calls should explicitly specify if they should be protected against SSRF` +
                " with gotSSRFProtected or gotSSRFUnprotected function from helpers/ssrfProtection",
            });
          }
        }
      },
    };
  },};

Eslint rule to detect unprotected got calls

This rule looks for got  function calls second argument.

The second argument in got is the HTTP request options.

We want our developers to explicitly wrap this options with gotSSRFProtected or gotSSRFUnprotected .

This way, everytime we use got , eslint will force us to think about the implication of this HTTP request.

Is it inter service communication ? → use gotSSRFUnprotected  then.

Is it user controlled URL ? → use gotSSRFProtected !

Wrapping options ?

What those two functions are doing ? You should ask yourself...

import { SocksProxyAgent } from 'socks-proxy-agent'import { Services } from '../services'

export function gotSSRFUnprotected<TOptions>(
  services: Services,
  options?: TOptions
) {
  return options
}

export function gotSSRFProtected<TOptions>(
  services: Services,
  options?: TOptions
) {
  if (services.config.ANTI_SSRF_PROXY_HOST !== '') {
    const info = {
      host: services.config.ANTI_SSRF_PROXY_HOST,
      userId: 'proxy_username',
      password: services.config.ANTI_SSRF_PROXY_PASSWORD,
    }

    return {
      agent: {
        http: new SocksProxyAgent(info),
        https: new SocksProxyAgent(info),
      },
      ...options,
    } as TOptions
  }
  return options
}

Anti-SSRF got protection

The unprotected function does nothing and the _protected _function injects an HTTP agent to enforce usage or our SOCKS5 proxy.

That's it ! We could do the same with axios or any other HTTP client library.

Triple check the library you use doesn't support other schema than https? as it will introduce nastier vulnerabilities (file://path/to/your/code...)!

Agents are only used for HTTP and HTTPS requests.

More...

We created a docker image wrapping all together: https://github.com/sliteteam/anti-ssrf-proxy

You can double check the block list

If you want to protect Dante SOCKS5 authentication with TLS, you can mount TLS certificate and private key and add the environment variable TLS=true.

It will bind Dante to localhost:1081  and use Stunnel4 to wrap it via 0.0.0.0:1080.

And... as socks-proxy-agent  nodejs library doesn't support TLS wrapped SOCKS5, we've made a PR and a fork @slite/socks-proxy-agent to introduce tls option in the library.

For our fellow bounty hunter out there

We've made an internal route to help you identify SSRF:

import { Request, Response } from 'express'import got from 'got'import { v4 as uuid } from 'uuid'import { gotSSRFProtected } from '../../helpers/ssrfProtection'

export async function testSSRF(request: Request, response: Response) {
  const { services } = response.locals
  const proof = uuid()
  services.sqreen.track('ssrf_triggered', { properties: { proof } })
  try {
    const pingURL = new URL(request.query.ping)
    pingURL.searchParams.set('proof', proof)
    await got(pingURL.toString(), gotSSRFProtected(services, { timeout: 3000 }))
    // eslint-disable-next-line no-empty
  } catch (e) {}

  response.json({ ok: true })}

Bounty hunter SSRF helper

If one of our pod do a request on http://production-api-externals/test-ssrf?ping=http://attacker-controlled.host/

We will receive a Sqreen alert and the attacker will receive a proof token to send us with its exploitation report.