Researchers at Zenity Labs reported three flaws in Salesforce Agentforce on June 1. Salesforce acknowledged them the next day, and the work ran through the summer. The Trusted URLs bypass was fixed and verified on August 18 and 19. Slack message attribution was corrected on the 20th. The last piece, requiring a person to confirm before an agent posts to Slack, wasn't finished until September 21. Zenity went public three days after that, under the name SalesBleed.
If you're running Agentforce, read the vendor notices and check your configuration. That's the practical part. It's also the smaller part.
The bigger part is that I've seen this bug before. So has anyone who was writing database code in the late nineties. Same defect, new place, and we're stuck at the stage of fixing it that took us most of a decade to get past the first time.
What actually happened
Someone submits a lead through a public Web-to-Lead form. No authentication, because that's the entire point of a lead form. The description field holds forty-two thousand characters, which is a lot of room to write something that isn't a sales inquiry.
Then nothing. The record just sits there.
Later, an employee does something completely ordinary. They ask their agent to check the latest leads and help with the newest one. The agent opens the record, and the text sitting in that description field doesn't get read as a lead description. It gets read as instructions.
After that it's a short walk. The default General CRM subagent ships with read access to Leads and to Accounts, so the injected text had a Query Records tool that could reach account names and deal sizes. None of which has anything to do with triaging a new lead.
Getting the data out was the clever bit. The agent was told to render an HTML image tag whose hostname encoded the stolen data as a subdomain. Nobody clicks anything. The browser tries to load the image, so it resolves the hostname, so the data leaves by DNS lookup. A second path ran through Slack, which crawls raw URLs automatically to build link previews. That request comes from Slack's own infrastructure, where your network monitoring can't see it.
Salesforce had a control meant to stop exactly this. It's called Trusted URLs, and it failed because it didn't recognize certain top-level domains, and because bracket and brace characters could confuse its URL parsing.
Read that last sentence again. It's the whole story.
We've met this bug
For most of the nineties we built database applications by pasting user input straight into a command string. You took whatever somebody typed into a form, concatenated it into a SELECT statement, and handed the finished string to the database.
I wrote code like that. Almost everyone did.
The database couldn't tell your part from the user's part. It got one string. Everything in that string had equal standing. So when a user typed something that looked like the end of your statement and the start of a new one, the database did what it was told. As far as it could see, you were the one telling it.
That's SalesBleed. A lead description is user-supplied text that got concatenated into an instruction stream and executed with the caller's privileges. The syntax is English instead of T-SQL. The defect is the same.
Our first attempt at a fix looked a lot like Trusted URLs, too. We escaped the input. Stripped quote marks, filtered keywords, kept blacklists of dangerous patterns. It failed over and over, and it always failed the same way. Somebody found a character encoding the filter didn't expect, or a syntax variant nobody had considered, or a nested construction that unwound differently than the parser assumed. Each defeat produced a slightly longer blacklist. The next attacker walked around that one too.
Trusted URLs didn't recognize certain TLDs. Brackets and braces confused the parser. That isn't a new category of failure. That's 1999, and we're getting 1999's results for 1999's reasons.
The fix we found, and the one we don't have
What finally killed SQL injection wasn't a better filter. It was parameterized queries. Bind variables.
The trick wasn't getting better at spotting bad input. It was refusing to mix the two together in the first place. Command goes over one channel, values go over another, and you tell the database which is which. Once the engine knows a given input is a value, nothing clever inside that value can promote it to an instruction. There's nothing left to escape, because there's no boundary left to break out of.
We don't have that for language models.
The model just gets text. All of it at once, in one piece: your system prompt, the records it pulled back, whatever the tools handed over, the question the user actually asked. None of it comes labeled. Nothing in there marks which part you wrote and which part a stranger typed into a form.
There's no bind variable. No way to hand the model a block of text and tell it, this part is data, it doesn't get to give orders, and I want that enforced by the plumbing instead of by the model's good judgment.
I'll be fair about where this stands. Serious people are working the problem and some of the approaches look promising: ranking inputs by how far they should be trusted, tagging untrusted text so the model handles it differently, splitting the work so the model doing the planning never sees the raw untrusted content at all. Something in there will probably become the parameterized query of this era. None of it is finished, and none of it is a setting you can switch on in your org this quarter.
So we're in the escaping years. And anywhere the defense depends on recognizing bad input, we're back to the old problem: filters eventually lose.
You can watch that difference in the patch schedule. The parser fix, the filter, was done and verified inside eleven weeks. The change that actually constrained what the agent could do on its own, putting a person in front of the Slack post, took until September. Maybe that's coincidence. But it's a familiar pattern. Filters are cheap to ship. Constraints cost somebody a product argument.
What's actually defensible right now
If you can't enforce the boundary, the only lever left is what a breach is worth. Not a new idea either.
The real defect in SalesBleed wasn't the URL parser. It was that an agent doing lead triage could reach the Accounts table at all. The injection got account names and deal sizes because they were sitting within reach. Scope that subagent to the job it was actually doing and the same successful injection comes back with nothing worth having. The parser bug decided how the data got out. The permission grant decided whether there was anything to take.
Security researcher Simon Willison calls the combination a "lethal trifecta": untrusted input reaching the model, privileged access sitting behind it, and a way out. Break any one link and the chain breaks. Two of those three are configuration decisions somebody made, usually by accepting a default.
Which puts us back on ground we already know how to hold, and the uncomfortable part is how little of it gets held on purpose. Nobody sat in a meeting and decided a lead triage agent should be able to read deal sizes. It shipped that way, somebody clicked through the setup, and the access was inherited rather than granted. Egress works the same way. An agent that can render a link or an image pointing somewhere you don't control has a channel out, and most teams find that out from a researcher instead of from their own review.
None of this is clever. It's least privilege, aimed at something new. Right now it's also most of what stands between a poisoned lead form and your pipeline.
What keeps my attention is that the exposure is about to get a lot wider. The agents announced this month are built to chase objectives over weeks instead of finishing single tasks. An agent working a three-week goal reads far more untrusted input along the way, makes more decisions between the injection and the consequence, and leaves a trail that's much harder to reconstruct afterward. We're growing the attack surface faster than we're learning to defend it.
Also not a new story. Just a faster one.