Skip to content

Commit d976aff

Browse files
authored
fix(search): harden live search accuracy, provider queries, and request cost (#8226)
1 parent 2d0353a commit d976aff

39 files changed

Lines changed: 2388 additions & 690 deletions

‎apps/sim/connectors/google-workspace/api-errors.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ const RATE_LIMIT_REASONS = new Set([
5454
'RATE_LIMIT_EXCEEDED',
5555
])
5656

57+
/** Whether a Google error reason reports an exhausted rate or usage quota rather than a denial. */
58+
export function isGoogleQuotaReason(reason: string): boolean {
59+
return (
60+
RATE_LIMIT_REASONS.has(reason) || reason === 'dailyLimitExceeded' || reason === 'quotaExceeded'
61+
)
62+
}
63+
5764
export function safeGoogleErrorReasons(reasons: readonly string[]): string[] {
5865
return [...new Set(reasons.filter((reason) => SAFE_REASONS.has(reason)))]
5966
}
@@ -119,13 +126,7 @@ export class GoogleApiError extends ConnectorSourceError {
119126
const safeReasons = safeGoogleErrorReasons(reasons)
120127
const suffix = safeReasons.length ? ` (${safeReasons.join(', ')})` : ''
121128
const category =
122-
status === 429 ||
123-
safeReasons.some(
124-
(reason) =>
125-
RATE_LIMIT_REASONS.has(reason) ||
126-
reason === 'dailyLimitExceeded' ||
127-
reason === 'quotaExceeded'
128-
)
129+
status === 429 || safeReasons.some(isGoogleQuotaReason)
129130
? 'rate_limit'
130131
: status >= 500
131132
? 'provider_unavailable'

‎apps/sim/connectors/utils.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
isIndexableConnectorFile,
7373
isSkippableMicrosoftGraphFolderError,
7474
isSkippedDocument,
75+
looksLikeHtml,
7576
MICROSOFT_GRAPH_MAX_CURSOR_ENCODED_BYTES,
7677
MICROSOFT_GRAPH_MAX_ITEM_ID_BYTES,
7778
MICROSOFT_GRAPH_MAX_PENDING_FOLDERS,
@@ -1754,3 +1755,14 @@ describe('BoundedLines', () => {
17541755
})
17551756
})
17561757
})
1758+
1759+
describe('looksLikeHtml', () => {
1760+
it('recognizes elements with valid tag syntax', () => {
1761+
for (const markup of ['<p>', '</p>', '<br>', '<br/>', '<a href="x">', '<td class="c">'])
1762+
expect(looksLikeHtml(`text ${markup} text`)).toBe(true)
1763+
})
1764+
it('does not mistake an address whose name starts like a tag for markup', () => {
1765+
for (const text of ['Invite <a@acme.com>', 'From <b.smith@acme.com>', '<i.e. later>'])
1766+
expect(looksLikeHtml(text)).toBe(false)
1767+
})
1768+
})

‎apps/sim/connectors/utils.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -339,11 +339,11 @@ function decodeCharacterReference(raw: string, code: number): string {
339339
* A false positive therefore does not merely pass text through untouched — it
340340
* deletes the bracketed span and flattens the document's line structure. Plain
341341
* text routinely contains angle brackets that are not markup: an email address
342-
* (`Reply from John <john@acme.com>`), a markdown autolink
342+
* (`Reply from John <john@acme.com>`, or `<a@acme.com>`, whose name is a tag's), a markdown autolink
343343
* (`<https://acme.com>`), or a placeholder (`<redacted>`).
344344
*/
345345
const HTML_TAG_PATTERN =
346-
/<\/?(?:p|div|br|hr|ul|ol|li|h[1-6]|table|thead|tbody|tr|td|th|span|strong|em|b|i|u|a|code|pre|blockquote|img|figure)\b[^>]*>/i
346+
/<\/?(?:p|div|br|hr|ul|ol|li|h[1-6]|table|thead|tbody|tr|td|th|span|strong|em|b|i|u|a|code|pre|blockquote|img|figure)(?=[\s/>])[^>]*>/i
347347

348348
/**
349349
* Reports whether a value carries real HTML markup and is therefore worth routing
@@ -362,15 +362,25 @@ export function looksLikeHtml(value: string): boolean {
362362
* punctuation as numeric references, which previously reached the index verbatim.
363363
*/
364364
export function htmlToPlainText(html: string): string {
365-
const text = html
366-
.replace(/<[^>]*>/g, ' ')
367-
.replace(HTML_ENTITY_PATTERN, (raw: string, hex?: string, decimal?: string, named?: string) => {
365+
return decodeHtmlEntities(html.replace(/<[^>]*>/g, ' '))
366+
.replace(/\s+/g, ' ')
367+
.trim()
368+
}
369+
370+
/**
371+
* Decodes HTML character references without touching markup or whitespace. Use for text a
372+
* provider HTML-escapes but does not mark up, such as Gmail message snippets.
373+
*/
374+
export function decodeHtmlEntities(text: string): string {
375+
return text.replace(
376+
HTML_ENTITY_PATTERN,
377+
(raw: string, hex?: string, decimal?: string, named?: string) => {
368378
if (named !== undefined) return NAMED_ENTITIES[named] ?? raw
369379
if (hex !== undefined) return decodeCharacterReference(raw, Number.parseInt(hex, 16))
370380
if (decimal !== undefined) return decodeCharacterReference(raw, Number.parseInt(decimal, 10))
371381
return raw
372-
})
373-
return text.replace(/\s+/g, ' ').trim()
382+
}
383+
)
374384
}
375385

376386
/**

‎apps/sim/lib/core/security/input-validation.server.ts‎

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,19 @@ export interface SecureFetchOptions {
356356
proxyUrl?: string
357357
/** Hide credential-derived URL details from validation logs. */
358358
logUrlValidationDetails?: boolean
359+
/**
360+
* Ask for a gzip or brotli body. The body is decoded before it is returned, and
361+
* `maxResponseBytes` bounds the decoded bytes, so a compression bomb still stops at the cap.
362+
*/
363+
acceptCompressed?: boolean
364+
/**
365+
* Reuses keep-alive connections to the same pinned address across requests. A connection is
366+
* only ever reused for the IP it was opened to, so every request keeps its DNS pinning. The
367+
* owner must call {@link PinnedConnectionPool.destroy} once its requests have finished. Bun
368+
* keeps sockets in its own per-address pool, so there reuse spans pools and `destroy` releases
369+
* only the agents.
370+
*/
371+
connectionPool?: PinnedConnectionPool
359372
/**
360373
* Where this request's URL came from. Carried on the options so the same
361374
* policy is re-applied to every redirect hop rather than re-derived — a hop
@@ -447,6 +460,43 @@ function resolveRedirectUrl(baseUrl: string, location: string): string {
447460
}
448461
}
449462

463+
/** Keep-alive agents keyed by protocol, host, port, and the pinned address they connect to. */
464+
export interface PinnedConnectionPool {
465+
/** Undefined once destroyed, so a late request falls back to a single-use pinned agent. */
466+
agent(isHttps: boolean, host: string, port: number, resolvedIP: string): http.Agent | undefined
467+
destroy(): void
468+
}
469+
470+
/**
471+
* Creates a request-scoped pool of pinned keep-alive agents. Reusing a connection skips the TCP
472+
* and TLS handshakes that otherwise dominate short provider API calls.
473+
*/
474+
export function createPinnedConnectionPool(): PinnedConnectionPool {
475+
const agents = new Map<string, http.Agent>()
476+
let destroyed = false
477+
return {
478+
agent(isHttps, host, port, resolvedIP) {
479+
if (destroyed) return undefined
480+
const key = JSON.stringify([isHttps, host, port, resolvedIP])
481+
let agent = agents.get(key)
482+
if (!agent) {
483+
const options: http.AgentOptions = {
484+
keepAlive: true,
485+
lookup: createPinnedLookup(resolvedIP),
486+
}
487+
agent = isHttps ? new https.Agent(options) : new http.Agent(options)
488+
agents.set(key, agent)
489+
}
490+
return agent
491+
},
492+
destroy() {
493+
destroyed = true
494+
for (const agent of agents.values()) agent.destroy()
495+
agents.clear()
496+
},
497+
}
498+
}
499+
450500
/**
451501
* Creates a DNS lookup function that always returns a pre-resolved IP address.
452502
* Use this to prevent DNS rebinding (TOCTOU) attacks when connecting to
@@ -1088,6 +1138,11 @@ export async function secureFetchWithPinnedIP(
10881138
const port = parsed.port ? Number.parseInt(parsed.port, 10) : defaultPort
10891139

10901140
let agent: http.Agent | undefined
1141+
/**
1142+
* Bun ignores a `lookup` set on an Agent and honors one on the request, while Node honors
1143+
* both. A pinned direct connection sets it in both places so pinning holds in either runtime.
1144+
*/
1145+
let pinnedLookup: LookupFunction | undefined
10911146
if (outboundDispatcher) {
10921147
agent = undefined
10931148
} else if (options.proxyUrl) {
@@ -1096,12 +1151,17 @@ export async function secureFetchWithPinnedIP(
10961151
// targets tunnel via CONNECT, http targets use absolute-URI forwarding.
10971152
agent = isHttps ? new HttpsProxyAgent(options.proxyUrl) : new HttpProxyAgent(options.proxyUrl)
10981153
} else {
1099-
const lookup = createPinnedLookup(resolvedIP)
1100-
const agentOptions: http.AgentOptions = { lookup }
1101-
agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions)
1154+
pinnedLookup = createPinnedLookup(resolvedIP)
1155+
agent =
1156+
options.connectionPool?.agent(isHttps, parsed.hostname, port, resolvedIP) ??
1157+
(isHttps
1158+
? new https.Agent({ lookup: pinnedLookup })
1159+
: new http.Agent({ lookup: pinnedLookup }))
11021160
}
11031161

11041162
const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {}
1163+
/** Raw deflate streams are ambiguous to decode, so only gzip and brotli are requested. */
1164+
if (options.acceptCompressed) sanitizedHeaders['accept-encoding'] = 'gzip, br'
11051165
if (!Object.keys(sanitizedHeaders).some((name) => name.toLowerCase() === 'user-agent')) {
11061166
sanitizedHeaders['user-agent'] = DEFAULT_USER_AGENT
11071167
}
@@ -1123,6 +1183,7 @@ export async function secureFetchWithPinnedIP(
11231183
method: options.method || 'GET',
11241184
headers: sanitizedHeaders,
11251185
agent,
1186+
...(pinnedLookup ? { lookup: pinnedLookup } : {}),
11261187
timeout: options.timeout || 300000,
11271188
}
11281189

@@ -1265,7 +1326,13 @@ export async function secureFetchWithPinnedIP(
12651326
statusCode === 204 ||
12661327
statusCode === 205 ||
12671328
statusCode === 304
1268-
const contentLength = headersRecord['content-length']
1329+
/**
1330+
* An encoded body's Content-Length is its wire size, not the decoded size the cap bounds,
1331+
* so only an identity body is rejected up front; the decoded stream is capped as it reads.
1332+
*/
1333+
const contentLength = headersRecord['content-encoding']
1334+
? undefined
1335+
: headersRecord['content-length']
12691336
if (contentLength && !isBodylessResponse) {
12701337
const parsedLength = Number.parseInt(contentLength, 10)
12711338
if (Number.isFinite(parsedLength) && parsedLength > maxResponseBytes) {

0 commit comments

Comments
 (0)