fix(security): harden SSO domain registration, webhook path isolation, and CSV export (#4813)

* fix(security): harden KB file access, SSO domain registration, webhook path isolation, env secrets, and CSV export

* fix(sso): scope domain conflict query with indexed lower(domain) filter

Address PR review: avoid a full-table scan on every SSO provider
registration by filtering candidate rows in SQL with
lower(domain) = <normalized>, keeping the in-memory ownership check.
Also tighten the normalizeSSODomain TSDoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: condense env route security comments

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* icons update

* chore(security): tighten inline comments in CSV export and KB file authorization

Condense verbose comment blocks to concise TSDoc/single-line form; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): validate internal serve origin in KB file authorization

Replace the bypassable isInternalFileUrl substring check in resolveInternalKbKey
with an origin allow-list (base URL, internal API base URL, TRUSTED_ORIGINS).
A crafted external host whose path is /api/files/serve/<victim-key> no longer
resolves to the victim key. Relative same-origin URLs are unaffected.

* style(sso): use idiomatic sql lower() comparison for domain conflict query

Match the repo's prevailing `sql`lower(col) = value`` idiom for the
case-insensitive SSO domain conflict lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): align workspace env admin gate with hasWorkspaceAdminAccess

Use the same admin check the secrets UI uses (owner, admin permission, or
org-admin) so owners and org-admins are not wrongly denied their own decrypted
workspace secrets, while read-only members remain restricted to names only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sso): rely on lower(domain) match for conflict detection, drop dead in-memory recheck

Address PR review: the SQL `lower(domain) = <normalized>` predicate already
excludes rows that the in-memory `normalizeSSODomain(...) === domain` recheck
claimed to catch, making that recheck dead/misleading code. Match on the
canonical lower-cased domain and filter purely by ownership. Malformed legacy
values (wildcards, schemes, ports) never match an email domain at sign-in, so
excluding them is not a gap. Test DB mock now applies the lower() predicate so
the casing-variant case is genuinely exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): scope webhook deploy path conflict to active webhooks

findConflictingWebhookPathOwner omitted the isActive filter that the
runtime dispatcher (findAllWebhooksForPath) applies, so an inactive but
non-archived webhook from another workflow (e.g. after undeploy or
failure auto-disable) would permanently block any new deployment on that
path even though it never receives deliveries. Align the guard with the
runtime isActive + archivedAt filter; the earliest-owner runtime check
remains the authoritative cross-tenant protection. Also trims verbose
TSDoc on the webhook path-isolation helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): exclude archived workflows from webhook deploy path conflict

findConflictingWebhookPathOwner now joins workflow and filters
isNull(workflow.archivedAt), matching the runtime dispatcher
(findAllWebhooksForPath). A webhook on an archived workflow can never
receive deliveries at runtime, so it must not block legitimate path reuse
with a 409.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): anchor KB file ownership to earliest document in any state

A KB file's owner is now the earliest document referencing its key regardless of
state (active/archived/deleted/excluded); access is granted only when that owning
document is still active. Closes the residual where an attacker could plant an
active document to claim a file whose original document was archived or deleted.

* updated greptile icon

* revert(security): drop KB file authorization changes

Reverts the knowledge-base file-access work (origin-pinning / owner-pinning /
origin allow-list in verifyKBFileAccess) and its test. The other hardening fixes
(SSO domain registration, webhook path isolation, workspace env secrets, CSV
export) are unchanged. apps/sim/app/api/files/authorization.ts is restored to its
origin/staging baseline.

* fix(sso): treat caller's own user-scoped provider as owned during conflict check

Self-hosters often register SSO user-scoped via the CLI script (no
SSO_ORGANIZATION_ID). If they later enable organizations and reconfigure the
same domain org-scoped through the UI, the conflict check previously treated
their own user-scoped row as another tenant's and returned a misleading 409.
Recognize the caller's own user-scoped provider as owned so that migration is
allowed, while still blocking another user's or another org's domain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* revert(security): remove workspace-env admin gate

Defer to a credential-based access model (separate change). Restores
GET /api/workspaces/[id]/environment to main behavior and removes the test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(security): consolidate webhook path-collision check into one helper

Extract findConflictingWebhookPathOwner to lib/webhooks/utils.server.ts as
the single source of truth for cross-tenant path-collision detection, used by
both webhook creation paths (deploy sync and the manual /api/webhooks route).

This also repairs two latent issues in the manual route's previous inline
check, which queried with limit(1) and only webhook.archivedAt:
- limit(1) inspected one arbitrary row, so a same-workflow row could mask a
  foreign collision (false negative). The shared helper scans all matching
  rows.
- It omitted isActive/workflow.archivedAt, so inactive or archived-workflow
  webhooks (which never receive deliveries) permanently blocked path reuse.
  The helper mirrors the runtime dispatcher's filter.

Same-workflow webhook reuse for upsert is now a separate, explicit lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-05-30 17:35:48 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 1d4a2772ab
commit a8f86c0c83
12 changed files with 956 additions and 136 deletions
+215 -50
View File
@@ -780,7 +780,7 @@ export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
export function SerperIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 654 600' xmlns='http://www.w3.org/2000/svg' {...props}>
<svg viewBox='35.411 -19.109 628.098 628.098' xmlns='http://www.w3.org/2000/svg' {...props}>
<path
d='M324 38C356 37 389 36 417 47C452 56 484 72 509 94C539 118 561 145 577 176C593 205 601 238 606 271C610 343 590 403 552 452C528 482 499 507 467 523C438 539 404 547 372 552C297 556 235 534 184 492C133 449 103 392 93 330C93 292 89 255 102 224C112 189 128 158 149 132C194 78 255 46 322 38'
fill='rgb(71,97,118)'
@@ -1298,7 +1298,7 @@ export function GoogleSheetsIcon(props: SVGProps<SVGSVGElement>) {
export const S3Icon = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
viewBox='-3.097 -3 70 70'
viewBox='0 0 80 80'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
@@ -2175,27 +2175,8 @@ export function MistralIcon(props: SVGProps<SVGSVGElement>) {
export function BrainIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M12 5a3 3 0 1 0-6.125 4 4 0 0 0-2.53 5.77 4 4 0 0 0 .556 6.59A4 4 0 1 0 12 18Z' />
<path d='M12 5a3 3 0 1 1 6.125 4 4 0 0 1 2.53 5.77 4 4 0 0 1-.556 6.59A4 4 0 1 1 12 18Z' />
<path d='M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4' />
<path d='M17.6 6.5a3 3 0 0 0 .399-1.37' />
<path d='M6 5.13A3 3 0 0 0 6.4 6.5' />
<path d='M3.48 10.9a4 4 0 0 1 .585-.396' />
<path d='M19.94 10.5a4 4 0 0 1 .585.4' />
<path d='M6 18a4 4 0 0 1-1.97-.516' />
<path d='M19.97 17.48A4 4 0 0 1 18 18' />
<svg {...props} viewBox='0 0 256 256' fill='currentColor' xmlns='http://www.w3.org/2000/svg'>
<path d='M212,76V72a44,44,0,0,0-74.86-31.31,3.93,3.93,0,0,0-1.14,2.8v88.72a4,4,0,0,0,6.2,3.33A47.67,47.67,0,0,1,167.68,128a8.18,8.18,0,0,1,8.31,7.58,8,8,0,0,1-8,8.42,32,32,0,0,0-32,32v33.88a4,4,0,0,0,1.49,3.12,47.92,47.92,0,0,0,74.21-17.16,4,4,0,0,0-4.49-5.56A68.06,68.06,0,0,1,192,192h-7.73a8.18,8.18,0,0,1-8.25-7.47,8,8,0,0,1,8-8.53h8a51.6,51.6,0,0,0,24-5.88v0A52,52,0,0,0,212,76Zm-12,36h-4a36,36,0,0,1-36-36V72a8,8,0,0,1,16,0v4a20,20,0,0,0,20,20h4a8,8,0,0,1,0,16ZM88,28A44.05,44.05,0,0,0,44,72v4a52,52,0,0,0-4,94.12h0A51.6,51.6,0,0,0,64,176h7.73A8.18,8.18,0,0,1,80,183.47,8,8,0,0,1,72,192H64a67.48,67.48,0,0,1-15.21-1.73,4,4,0,0,0-4.5,5.55A47.93,47.93,0,0,0,118.51,213a4,4,0,0,0,1.49-3.12V176a32,32,0,0,0-32-32,8,8,0,0,1-8-8.42A8.18,8.18,0,0,1,88.32,128a47.67,47.67,0,0,1,25.48,7.54,4,4,0,0,0,6.2-3.33V43.49a4,4,0,0,0-1.14-2.81A43.85,43.85,0,0,0,88,28Zm8,48a36,36,0,0,1-36,36H56a8,8,0,0,1,0-16h4A20,20,0,0,0,80,76V72a8,8,0,0,1,16,0Z' />
</svg>
)
}
@@ -2342,8 +2323,8 @@ export function ExtendIcon(props: SVGProps<SVGSVGElement>) {
export function EvernoteIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='#7fce2c'>
<path d='M29.34 16.82c.1 1.7-.08 3.37-.305 5.05-.225 1.71-.508 3.42-.964 5.08-.3 1.07-.673 2.1-1.2 3.07-.65 1.19-1.63 1.87-2.99 1.92l-3.83.036c-.636-.017-1.28-.146-1.9-.297-1.19-.3-1.86-1.1-2.06-2.3-.186-1.08-.173-2.19.04-3.26.252-1.23 1-1.96 2.23-2.1.817-.1 1.65-.077 2.48-.1.21-.7.28.98.2.287-.196.53-.236 1.07-.098 1.62.53.21-.23.31-.26.31a7.77 7.77 0 0 0-1.12.053c-.636.09-.96.47-.96 1.11 0 .205.03.416.07.622.1.507.45.78.94.837 1.12.127 2.25.138 3.37-.5.68-.114 1.08-.54 1.16-1.21.152-1.3.16-2.59-.228-3.84-.33-1.09-1.01-1.56-2.13-1.7l-3.36-.54c-1.06-.193-1.7-.887-1.92-1.9-.13-.572-.14-1.17-.214-1.76-.013-.106-.074-.208-.1-.3-.04.1-.106.21-.117.33-.066.68-.053 1.37-.185 2.04-.16.8-.404 1.57-.67 2.33-.185.54-.616.84-1.2.8a37.76 37.76 0 0 1-7.12-1.35l-.64-.207c-.927-.26-1.49-.903-1.74-1.79l-1-3.85-.74-4.3c-.115-.755-.2-1.52-.083-2.29.154-1.11.914-1.9 2.04-1.96l3.56-.062c.127 0 .254.373-.026a1.23 1.23 0 0 0 1.01-1.25l-.05-3.04c-.048-1.58.8-2.38 2.16-2.62a10.58 10.58 0 0 1 4.91.26c.933.28 1.47.923 1.72 1.83.06.22.15.3.37.29l2.58.01 3.33.37c.686.1 1.36.25 2.03.42 1.17.298 1.79 1.11 1.96 2.26l.357 3.36.3 5.58.01 2.28zm-4.53-1.15c-.02-.666-.07-1.27-.444-1.78a1.66 1.66 0 0 0-2.47-.15c-.364.4-.494.88-.564 1.4-.8.03.106.13.16.13l.8-.053c.768.01 1.52.113 2.25.39.66.03.136.4.26.077zM8.79 1.15a3.82 3.82 0 0 0-.278 1.59l.05 2.93c.5.36-.075.45-.433.45L5.1 6.16c-.583 0-1.14.1-1.55.278l5.2-5.33c.2.01.4.03.6.05z' />
<svg {...props} viewBox='0 0 32 32' fill='#7fce2c' xmlns='http://www.w3.org/2000/svg'>
<path d='M29.343 16.818c.1 1.695-.08 3.368-.305 5.045-.225 1.712-.508 3.416-.964 5.084-.3 1.067-.673 2.1-1.202 3.074-.65 1.192-1.635 1.87-2.992 1.924l-3.832.036c-.636-.017-1.278-.146-1.9-.297-1.192-.3-1.862-1.1-2.06-2.3-.186-1.08-.173-2.187.04-3.264.252-1.23 1-1.96 2.234-2.103.817-.1 1.65-.077 2.476-.1.205-.007.275.098.203.287-.196.53-.236 1.07-.098 1.623.053.207-.023.307-.26.305a7.77 7.77 0 0 0-1.123.053c-.636.086-.96.47-.96 1.112 0 .205.026.416.066.622.103.507.45.78.944.837 1.123.127 2.247.138 3.37-.05.675-.114 1.08-.54 1.16-1.208.152-1.3.155-2.587-.228-3.845-.33-1.092-1.006-1.565-2.134-1.7l-3.36-.54c-1.06-.193-1.7-.887-1.92-1.9-.13-.572-.14-1.17-.214-1.757-.013-.106-.074-.208-.1-.3-.04.1-.106.212-.117.326-.066.68-.053 1.373-.185 2.04-.16.8-.404 1.566-.67 2.33-.185.535-.616.837-1.205.8a37.76 37.76 0 0 1-7.123-1.353l-.64-.207c-.927-.26-1.487-.903-1.74-1.787l-1-3.853-.74-4.3c-.115-.755-.2-1.523-.083-2.293.154-1.112.914-1.903 2.04-1.964l3.558-.062c.127 0 .254.003.373-.026a1.23 1.23 0 0 0 1.01-1.255l-.05-3.036c-.048-1.576.8-2.38 2.156-2.622a10.58 10.58 0 0 1 4.91.26c.933.275 1.467.923 1.715 1.83.058.22.146.3.37.287l2.582.01 3.333.37c.686.095 1.364.25 2.032.42 1.165.298 1.793 1.112 1.962 2.256l.357 3.355.3 5.577.01 2.277zm-4.534-1.155c-.02-.666-.07-1.267-.444-1.784a1.66 1.66 0 0 0-2.469-.15c-.364.4-.494.88-.564 1.4-.008.034.106.126.16.126l.8-.053c.768.007 1.523.113 2.25.393.066.026.136.04.265.077zM8.787 1.154a3.82 3.82 0 0 0-.278 1.592l.05 2.934c.005.357-.075.45-.433.45L5.1 6.156c-.583 0-1.143.1-1.554.278l5.2-5.332c.02.013.04.033.06.053z' />
</svg>
)
}
@@ -2538,7 +2519,7 @@ export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
viewBox='1 1 22 22'
width='24'
height='24'
fill='none'
@@ -3635,26 +3616,200 @@ export function HunterIOIcon(props: SVGProps<SVGSVGElement>) {
}
export function MicrosoftOneDriveIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
return (
<svg {...props} fill='currentColor' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'>
<g>
<path
d='M12.2,11.19l.00031-.0011,6.72,4.02,4-1.68.18A6.48,6.48,0,0,1,25.5,13c.14764,0,.29358.1.44.02a10,10,0,0,0-18.04-3.01C7.93,10,7.97,10,8,10A7.96,7.96,0,0,1,12.2,11.19Z'
fill='#0364b8'
/>
<path
d='M12.2,11.19l-.31A7.96,7.96,0,0,0,8,10c-.0343,0-.6805-.10223A8,8,0,0,0,1.44,22.57l5.92-2.49,2.63-1.11,5.86-2.47,3.06-1.29Z'
fill='#0078d4'
/>
<path
d='M25.94,13.02C25.79,13.01,25.65,13,25.5,13a6.48,6.48,0,0,0-2.58.53l-.00018-.00068-4,1.68,1.16.7L23.89,18.19l1.66.99,5.68,3.4a6.5,6.5,0,0,0-5.28-9.57Z'
fill='#1490df'
/>
<path
d='M25.55,19.18,23.89,18.19l-3.8-2.28-1.16-.69528L15.86,16.5,9.99,18.97,7.36,20.08l-5.92,2.49A7.99,7.99,0,0,0,8,26H25.5a6.5,6.5,0,0,0,5.72-3.42Z'
fill='#28a8ea'
/>
</g>
<svg {...props} viewBox='35.98 139.2 648.03 430.85' xmlns='http://www.w3.org/2000/svg'>
<defs>
<radialGradient
id={`onedrive_radial0_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(130.864814,156.804864,-260.089994,217.063603,48.669602,228.766494)'
>
<stop offset='0' stopColor='rgb(28.235294%,58.039216%,99.607843%)' stopOpacity={1} />
<stop
offset='0.695072'
stopColor='rgb(3.529412%,20.392157%,70.196078%)'
stopOpacity={1}
/>
</radialGradient>
<radialGradient
id={`onedrive_radial1_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-575.289668,663.594003,-491.728488,-426.294267,596.956501,-6.380235)'
>
<stop
offset='0.165327'
stopColor='rgb(13.72549%,75.294118%,99.607843%)'
stopOpacity={1}
/>
<stop offset='0.534' stopColor='rgb(10.980392%,56.862745%,100%)' stopOpacity={1} />
</radialGradient>
<radialGradient
id={`onedrive_radial2_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-136.753383,-114.806698,262.816935,-313.057562,181.196995,240.395994)'
>
<stop offset='0' stopColor='rgb(100%,100%,100%)' stopOpacity={0.4} />
<stop offset='0.660528' stopColor='rgb(67.843137%,75.294118%,100%)' stopOpacity={0} />
</radialGradient>
<radialGradient
id={`onedrive_radial3_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-153.638428,-130.000063,197.433014,-233.332948,375.353994,451.43549)'
>
<stop offset='0' stopColor='rgb(1.176471%,22.745098%,80%)' stopOpacity={1} />
<stop offset='1' stopColor='rgb(21.176471%,55.686275%,100%)' stopOpacity={0} />
</radialGradient>
<radialGradient
id={`onedrive_radial4_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(175.585899,405.198026,-437.434522,189.555055,169.378495,125.589294)'
>
<stop
offset='0.592618'
stopColor='rgb(20.392157%,39.215686%,89.019608%)'
stopOpacity={0}
/>
<stop offset='1' stopColor='rgb(1.176471%,22.745098%,80%)' stopOpacity={0.6} />
</radialGradient>
<radialGradient
id={`onedrive_radial5_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-459.329491,459.329491,-719.614455,-719.614455,589.876499,39.484649)'
>
<stop
offset='0'
stopColor='rgb(29.411765%,99.215686%,90.980392%)'
stopOpacity={0.898039}
/>
<stop
offset='0.543937'
stopColor='rgb(29.411765%,99.215686%,90.980392%)'
stopOpacity={0}
/>
</radialGradient>
<linearGradient
id={`onedrive_linear0_${id}`}
gradientUnits='userSpaceOnUse'
x1='29.999701'
y1='37.9823'
x2='29.999701'
y2='18.398199'
gradientTransform='matrix(15,0,0,15,0,0)'
>
<stop offset='0' stopColor='rgb(0%,52.54902%,100%)' stopOpacity={1} />
<stop offset='0.49' stopColor='rgb(0%,73.333333%,100%)' stopOpacity={1} />
</linearGradient>
<radialGradient
id={`onedrive_radial6_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(273.622108,108.513684,-205.488428,518.148261,296.488495,307.441492)'
>
<stop offset='0' stopColor='rgb(100%,100%,100%)' stopOpacity={0.4} />
<stop offset='0.785262' stopColor='rgb(100%,100%,100%)' stopOpacity={0} />
</radialGradient>
<radialGradient
id={`onedrive_radial7_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-305.683909,263.459223,-264.352324,-306.720147,674.845505,249.378004)'
>
<stop
offset='0'
stopColor='rgb(29.411765%,99.215686%,90.980392%)'
stopOpacity={0.898039}
/>
<stop
offset='0.584724'
stopColor='rgb(29.411765%,99.215686%,90.980392%)'
stopOpacity={0}
/>
</radialGradient>
</defs>
<path
fill={`url(#onedrive_radial0_${id})`}
fillRule='nonzero'
d='M 215.078125 205.089844 C 116.011719 205.09375 41.957031 286.1875 36.382812 376.527344 C 39.835938 395.992188 51.175781 434.429688 68.941406 432.457031 C 91.144531 429.988281 147.066406 432.457031 194.765625 346.105469 C 229.609375 283.027344 301.285156 205.085938 215.078125 205.089844 Z M 215.078125 205.089844 '
/>
<path
fill={`url(#onedrive_radial1_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_radial2_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_radial3_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_radial4_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_radial5_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_linear0_${id})`}
fillRule='nonzero'
d='M 215.699219 569.496094 C 215.699219 569.496094 489.320312 570.035156 535.734375 570.035156 C 619.960938 570.035156 684 501.273438 684 421.03125 C 684 340.789062 618.671875 272.445312 535.734375 272.445312 C 452.792969 272.445312 405.027344 334.492188 369.152344 402.226562 C 327.117188 481.59375 273.488281 568.546875 215.699219 569.496094 Z M 215.699219 569.496094 '
/>
<path
fill={`url(#onedrive_radial6_${id})`}
fillRule='nonzero'
d='M 215.699219 569.496094 C 215.699219 569.496094 489.320312 570.035156 535.734375 570.035156 C 619.960938 570.035156 684 501.273438 684 421.03125 C 684 340.789062 618.671875 272.445312 535.734375 272.445312 C 452.792969 272.445312 405.027344 334.492188 369.152344 402.226562 C 327.117188 481.59375 273.488281 568.546875 215.699219 569.496094 Z M 215.699219 569.496094 '
/>
<path
fill={`url(#onedrive_radial7_${id})`}
fillRule='nonzero'
d='M 215.699219 569.496094 C 215.699219 569.496094 489.320312 570.035156 535.734375 570.035156 C 619.960938 570.035156 684 501.273438 684 421.03125 C 684 340.789062 618.671875 272.445312 535.734375 272.445312 C 452.792969 272.445312 405.027344 334.492188 369.152344 402.226562 C 327.117188 481.59375 273.488281 568.546875 215.699219 569.496094 Z M 215.699219 569.496094 '
/>
</svg>
)
}
@@ -5882,12 +6037,22 @@ export function GreenhouseIcon(props: SVGProps<SVGSVGElement>) {
export function GreptileIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
<svg {...props} viewBox='0 0 367 420' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
clipRule='evenodd'
fillRule='evenodd'
fill='#44A775'
d='M3.353.004a6.074 6.074 0 01-.265.045C2.63.12 2.092.348 1.71.633 1.426.846.717 1.575.557 1.819a3.359 3.359 0 00-.23 3.296c.154.322.35.59.71.972.187.198.434.486.55.64a6.629 6.629 0 011.305 3.546c.01.138.035 1.607.057 3.264.043 3.273.038 3.18.203 3.485.266.494.94.79 1.474.648.29-.077.463-.204 1.353-.986.957-.84 1.092-.932 1.446-.98.124-.017.631 0 1.66.053 1.513.08 1.622.079 1.85-.016.393-.164.539-.4.661-1.074.247-1.36 1.296-2.56 2.64-3.022.116-.04.373-.104.572-.144.198-.04.426-.102.506-.138.296-.136.515-.424.566-.744.017-.11-.007-.549-.089-1.602-.091-1.179-.107-1.483-.083-1.621.057-.342.139-.46 1.01-1.448.447-.506.85-.976.895-1.043.262-.39.288-.91.068-1.345a1.44 1.44 0 00-.822-.67c-.1-.029-.834-.037-3.544-.038H9.897l-.335-.063c-.958-.179-1.765-.49-2.484-.958-.362-.236-.583-.41-1.018-.804-.408-.37-.59-.502-.921-.67A3.018 3.018 0 003.744.005a3.942 3.942 0 00-.391 0zm15.728 5.858c-.132.049-.217.127-.48.44-.592.707-.74 1.336-.531 2.256.106.466.163.572.361.673.105.054.169.055 2.637.046l2.53-.009.118-.063a.551.551 0 00.095-.895 184.88 184.88 0 00-2.223-1.254c-2.293-1.282-2.281-1.276-2.507-1.194zm-3.216 6.71a9.258 9.258 0 00-1.364.696c-.844.557-1.454 1.36-1.923 2.53-.211.525-.202.75.04.935.111.087 6.478 3.14 6.667 3.198.153.047.27.027.43-.074a.538.538 0 00.24-.434c0-.06-.03-.18-.065-.264-.156-.368-3.098-6.467-3.158-6.545-.168-.222-.394-.232-.867-.042zm-8.48 5.457c-.453.102-.83.32-1.285.745-.296.277-.336.468-.167.798.053.103.61 1.104 1.236 2.224 1.297 2.317 1.22 2.206 1.558 2.202.152-.002.198-.015.296-.084a.662.662 0 00.173-.193c.058-.11.06-.152.08-2.595.018-1.93.015-2.51-.011-2.606a.569.569 0 00-.138-.227c-.09-.091-.14-.112-.406-.176-.582-.138-.992-.165-1.336-.088z'
d='M240.269 49.8154L166.804 115.963L115.966 159.44L181.335 220.585L249.784 162.048L196.78 112.47L253.068 61.7881L362.605 164.246L178.739 321.489L3.14502 157.242L187.011 0L240.269 49.8154Z'
fill='#3D3B4F'
/>
<rect
width='236.453'
height='83.4566'
transform='matrix(0.75471 -0.656059 0 1 188.017 336.544)'
fill='#3D3B4F'
/>
<rect
width='236.453'
height='83.4566'
transform='matrix(0.731354 0.681998 0 1 0 174.962)'
fill='#3D3B4F'
/>
</svg>
)
@@ -0,0 +1,196 @@
/**
* @vitest-environment node
*/
import { createEnvMock, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetSession,
mockRegisterSSOProvider,
mockHasSSOAccess,
mockValidateUrlWithDNS,
dbState,
memberTable,
ssoProviderTable,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockRegisterSSOProvider: vi.fn(),
mockHasSSOAccess: vi.fn(),
mockValidateUrlWithDNS: vi.fn(),
dbState: { members: [] as any[], providers: [] as any[] },
memberTable: {
userId: 'member.userId',
organizationId: 'member.organizationId',
role: 'member.role',
},
ssoProviderTable: {
id: 'sso.id',
providerId: 'sso.providerId',
domain: 'sso.domain',
issuer: 'sso.issuer',
userId: 'sso.userId',
organizationId: 'sso.organizationId',
oidcConfig: 'sso.oidcConfig',
samlConfig: 'sso.samlConfig',
},
}))
function makeBuilder(rows: any[]): any {
const thenable: any = Promise.resolve(rows)
thenable.where = (condition: any) => {
const values = condition?.values
if (Array.isArray(values) && values.length > 0) {
const target = String(values[values.length - 1]).toLowerCase()
return makeBuilder(rows.filter((r) => String(r.domain ?? '').toLowerCase() === target))
}
return makeBuilder(rows)
}
thenable.limit = () => Promise.resolve(rows)
thenable.orderBy = () => Promise.resolve(rows)
return thenable
}
vi.mock('@sim/db', () => ({
db: {
select: () => ({
from: (table: unknown) =>
makeBuilder(table === memberTable ? dbState.members : dbState.providers),
}),
},
member: memberTable,
ssoProvider: ssoProviderTable,
}))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
auth: { api: { registerSSOProvider: mockRegisterSSOProvider } },
}))
vi.mock('@/lib/billing', () => ({
hasSSOAccess: mockHasSSOAccess,
}))
vi.mock('@/lib/auth/sso/domain', () => ({
normalizeSSODomain: (input: unknown): string | null => {
if (typeof input !== 'string') return null
const value = input.trim().toLowerCase()
return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value) ? value : null
},
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: mockValidateUrlWithDNS,
secureFetchWithPinnedIP: vi.fn(),
}))
vi.mock('@/lib/core/config/env', () => createEnvMock({ SSO_ENABLED: 'true' }))
import { POST } from '@/app/api/auth/sso/register/route'
const OIDC_BODY = {
providerType: 'oidc' as const,
providerId: 'acme-oidc',
issuer: 'https://idp.acme.com',
domain: 'acme.com',
clientId: 'client-id',
clientSecret: 'client-secret',
authorizationEndpoint: 'https://idp.acme.com/authorize',
tokenEndpoint: 'https://idp.acme.com/token',
userInfoEndpoint: 'https://idp.acme.com/userinfo',
jwksEndpoint: 'https://idp.acme.com/jwks',
}
function request(body: Record<string, unknown>) {
return createMockRequest('POST', body)
}
describe('POST /api/auth/sso/register', () => {
beforeEach(() => {
vi.clearAllMocks()
dbState.members = []
dbState.providers = []
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
mockHasSSOAccess.mockResolvedValue(true)
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' })
mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
})
it('rejects callers without an Enterprise plan', async () => {
mockHasSSOAccess.mockResolvedValue(false)
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects callers who are not an admin/owner of the target org', async () => {
dbState.members = [{ organizationId: 'org1', role: 'member' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects an invalid domain', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' }))
expect(res.status).toBe(400)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects a domain already registered by another organization', async () => {
dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' }))
const json = await res.json()
expect(res.status).toBe(409)
expect(json.code).toBe('SSO_DOMAIN_ALREADY_REGISTERED')
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('matches conflicts across casing variants', async () => {
dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }]
dbState.providers = [{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' }))
expect(res.status).toBe(409)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('registers when the domain is unclaimed', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it('allows the owning tenant to update its own provider for the same domain', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it('lets an org admin adopt their own user-scoped provider for the same domain', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: null }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it("still blocks an org admin from claiming another user's user-scoped domain", async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
dbState.providers = [{ domain: 'acme.com', userId: 'someone-else', organizationId: null }]
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(409)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('normalizes the domain before persisting it', async () => {
dbState.members = [{ organizationId: 'org1', role: 'owner' }]
const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.domain).toBe('acme.com')
})
})
+40 -2
View File
@@ -1,11 +1,12 @@
import { db, member, ssoProvider } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth'
import { normalizeSSODomain } from '@/lib/auth/sso/domain'
import { hasSSOAccess } from '@/lib/billing'
import { env } from '@/lib/core/config/env'
import {
@@ -51,7 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response
const body = parsed.data.body
const { providerId, issuer, domain, providerType, mapping, orgId } = body
const { providerId, issuer, providerType, mapping, orgId } = body
if (orgId) {
const [membership] = await db
@@ -67,6 +68,43 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
}
const domain = normalizeSSODomain(body.domain)
if (!domain) {
return NextResponse.json({ error: 'Enter a valid domain like company.com' }, { status: 400 })
}
const isOwnedByCaller = (provider: {
userId: string | null
organizationId: string | null
}): boolean => {
if (provider.userId === session.user.id && !provider.organizationId) return true
return orgId ? provider.organizationId === orgId : false
}
const existingProviders = await db
.select({
userId: ssoProvider.userId,
organizationId: ssoProvider.organizationId,
})
.from(ssoProvider)
.where(sql`lower(${ssoProvider.domain}) = ${domain}`)
const conflictingProvider = existingProviders.find((provider) => !isOwnedByCaller(provider))
if (conflictingProvider) {
logger.warn('Rejected SSO registration for domain owned by another tenant', {
domain,
orgId,
userId: session.user.id,
})
return NextResponse.json(
{
error: 'This domain is already registered for SSO by another organization.',
code: 'SSO_DOMAIN_ALREADY_REGISTERED',
},
{ status: 409 }
)
}
const headers: Record<string, string> = {}
request.headers.forEach((value, key) => {
headers[key] = value
@@ -53,7 +53,9 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
const encoder = new TextEncoder()
try {
if (format === 'csv') {
controller.enqueue(encoder.encode(`${toCsvRow(columns.map((c) => c.name))}\n`))
controller.enqueue(
encoder.encode(`${toCsvRow(columns.map((c) => neutralizeCsvFormula(c.name)))}\n`)
)
} else {
controller.enqueue(encoder.encode('['))
}
@@ -111,10 +113,23 @@ function sanitizeFilename(name: string): string {
return cleaned || 'table'
}
/**
* Prefixes a single quote to values starting with a spreadsheet formula trigger
* (`=`, `+`, `-`, `@`, tab, CR), neutralizing CSV injection in Excel/Sheets.
*/
function neutralizeCsvFormula(value: string): string {
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
}
/**
* Serializes a cell for CSV. Only string cells are formula-neutralized; numbers,
* booleans, dates, and JSON objects can never form a trigger and pass through verbatim.
*/
function formatCsvValue(value: unknown): string {
if (value === null || value === undefined) return ''
if (value instanceof Date) return value.toISOString()
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string') return neutralizeCsvFormula(value)
return String(value)
}
+27 -14
View File
@@ -27,7 +27,10 @@ import {
} from '@/lib/webhooks/provider-subscriptions'
import { getProviderHandler } from '@/lib/webhooks/providers'
import { mergeNonUserFields } from '@/lib/webhooks/utils'
import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import {
findConflictingWebhookPathOwner,
syncWebhooksForCredentialSet,
} from '@/lib/webhooks/utils.server'
import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants'
const logger = createLogger('WebhooksAPI')
@@ -330,21 +333,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
}
if (!targetWebhookId) {
const existingByPath = await db
.select({ id: webhook.id, workflowId: webhook.workflowId })
const conflictingOwner = await findConflictingWebhookPathOwner({
path: finalPath,
workflowId,
})
if (conflictingOwner) {
logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`)
return NextResponse.json(
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
{ status: 409 }
)
}
const ownExisting = await db
.select({ id: webhook.id })
.from(webhook)
.where(and(eq(webhook.path, finalPath), isNull(webhook.archivedAt)))
.limit(1)
if (existingByPath.length > 0) {
// If a webhook with the same path exists but belongs to a different workflow, return an error
if (existingByPath[0].workflowId !== workflowId) {
logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`)
return NextResponse.json(
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
{ status: 409 }
.where(
and(
eq(webhook.path, finalPath),
eq(webhook.workflowId, workflowId),
isNull(webhook.archivedAt)
)
}
targetWebhookId = existingByPath[0].id
)
.limit(1)
if (ownExisting.length > 0) {
targetWebhookId = ownExisting[0].id
}
}
+215 -50
View File
@@ -780,7 +780,7 @@ export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
export function SerperIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 654 600' xmlns='http://www.w3.org/2000/svg' {...props}>
<svg viewBox='35.411 -19.109 628.098 628.098' xmlns='http://www.w3.org/2000/svg' {...props}>
<path
d='M324 38C356 37 389 36 417 47C452 56 484 72 509 94C539 118 561 145 577 176C593 205 601 238 606 271C610 343 590 403 552 452C528 482 499 507 467 523C438 539 404 547 372 552C297 556 235 534 184 492C133 449 103 392 93 330C93 292 89 255 102 224C112 189 128 158 149 132C194 78 255 46 322 38'
fill='rgb(71,97,118)'
@@ -1298,7 +1298,7 @@ export function GoogleSheetsIcon(props: SVGProps<SVGSVGElement>) {
export const S3Icon = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
viewBox='-3.097 -3 70 70'
viewBox='0 0 80 80'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
xmlnsXlink='http://www.w3.org/1999/xlink'
@@ -2175,27 +2175,8 @@ export function MistralIcon(props: SVGProps<SVGSVGElement>) {
export function BrainIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M12 5a3 3 0 1 0-6.125 4 4 0 0 0-2.53 5.77 4 4 0 0 0 .556 6.59A4 4 0 1 0 12 18Z' />
<path d='M12 5a3 3 0 1 1 6.125 4 4 0 0 1 2.53 5.77 4 4 0 0 1-.556 6.59A4 4 0 1 1 12 18Z' />
<path d='M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4' />
<path d='M17.6 6.5a3 3 0 0 0 .399-1.37' />
<path d='M6 5.13A3 3 0 0 0 6.4 6.5' />
<path d='M3.48 10.9a4 4 0 0 1 .585-.396' />
<path d='M19.94 10.5a4 4 0 0 1 .585.4' />
<path d='M6 18a4 4 0 0 1-1.97-.516' />
<path d='M19.97 17.48A4 4 0 0 1 18 18' />
<svg {...props} viewBox='0 0 256 256' fill='currentColor' xmlns='http://www.w3.org/2000/svg'>
<path d='M212,76V72a44,44,0,0,0-74.86-31.31,3.93,3.93,0,0,0-1.14,2.8v88.72a4,4,0,0,0,6.2,3.33A47.67,47.67,0,0,1,167.68,128a8.18,8.18,0,0,1,8.31,7.58,8,8,0,0,1-8,8.42,32,32,0,0,0-32,32v33.88a4,4,0,0,0,1.49,3.12,47.92,47.92,0,0,0,74.21-17.16,4,4,0,0,0-4.49-5.56A68.06,68.06,0,0,1,192,192h-7.73a8.18,8.18,0,0,1-8.25-7.47,8,8,0,0,1,8-8.53h8a51.6,51.6,0,0,0,24-5.88v0A52,52,0,0,0,212,76Zm-12,36h-4a36,36,0,0,1-36-36V72a8,8,0,0,1,16,0v4a20,20,0,0,0,20,20h4a8,8,0,0,1,0,16ZM88,28A44.05,44.05,0,0,0,44,72v4a52,52,0,0,0-4,94.12h0A51.6,51.6,0,0,0,64,176h7.73A8.18,8.18,0,0,1,80,183.47,8,8,0,0,1,72,192H64a67.48,67.48,0,0,1-15.21-1.73,4,4,0,0,0-4.5,5.55A47.93,47.93,0,0,0,118.51,213a4,4,0,0,0,1.49-3.12V176a32,32,0,0,0-32-32,8,8,0,0,1-8-8.42A8.18,8.18,0,0,1,88.32,128a47.67,47.67,0,0,1,25.48,7.54,4,4,0,0,0,6.2-3.33V43.49a4,4,0,0,0-1.14-2.81A43.85,43.85,0,0,0,88,28Zm8,48a36,36,0,0,1-36,36H56a8,8,0,0,1,0-16h4A20,20,0,0,0,80,76V72a8,8,0,0,1,16,0Z' />
</svg>
)
}
@@ -2342,8 +2323,8 @@ export function ExtendIcon(props: SVGProps<SVGSVGElement>) {
export function EvernoteIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='#7fce2c'>
<path d='M29.34 16.82c.1 1.7-.08 3.37-.305 5.05-.225 1.71-.508 3.42-.964 5.08-.3 1.07-.673 2.1-1.2 3.07-.65 1.19-1.63 1.87-2.99 1.92l-3.83.036c-.636-.017-1.28-.146-1.9-.297-1.19-.3-1.86-1.1-2.06-2.3-.186-1.08-.173-2.19.04-3.26.252-1.23 1-1.96 2.23-2.1.817-.1 1.65-.077 2.48-.1.21-.7.28.98.2.287-.196.53-.236 1.07-.098 1.62.53.21-.23.31-.26.31a7.77 7.77 0 0 0-1.12.053c-.636.09-.96.47-.96 1.11 0 .205.03.416.07.622.1.507.45.78.94.837 1.12.127 2.25.138 3.37-.5.68-.114 1.08-.54 1.16-1.21.152-1.3.16-2.59-.228-3.84-.33-1.09-1.01-1.56-2.13-1.7l-3.36-.54c-1.06-.193-1.7-.887-1.92-1.9-.13-.572-.14-1.17-.214-1.76-.013-.106-.074-.208-.1-.3-.04.1-.106.21-.117.33-.066.68-.053 1.37-.185 2.04-.16.8-.404 1.57-.67 2.33-.185.54-.616.84-1.2.8a37.76 37.76 0 0 1-7.12-1.35l-.64-.207c-.927-.26-1.49-.903-1.74-1.79l-1-3.85-.74-4.3c-.115-.755-.2-1.52-.083-2.29.154-1.11.914-1.9 2.04-1.96l3.56-.062c.127 0 .254.373-.026a1.23 1.23 0 0 0 1.01-1.25l-.05-3.04c-.048-1.58.8-2.38 2.16-2.62a10.58 10.58 0 0 1 4.91.26c.933.28 1.47.923 1.72 1.83.06.22.15.3.37.29l2.58.01 3.33.37c.686.1 1.36.25 2.03.42 1.17.298 1.79 1.11 1.96 2.26l.357 3.36.3 5.58.01 2.28zm-4.53-1.15c-.02-.666-.07-1.27-.444-1.78a1.66 1.66 0 0 0-2.47-.15c-.364.4-.494.88-.564 1.4-.8.03.106.13.16.13l.8-.053c.768.01 1.52.113 2.25.39.66.03.136.4.26.077zM8.79 1.15a3.82 3.82 0 0 0-.278 1.59l.05 2.93c.5.36-.075.45-.433.45L5.1 6.16c-.583 0-1.14.1-1.55.278l5.2-5.33c.2.01.4.03.6.05z' />
<svg {...props} viewBox='0 0 32 32' fill='#7fce2c' xmlns='http://www.w3.org/2000/svg'>
<path d='M29.343 16.818c.1 1.695-.08 3.368-.305 5.045-.225 1.712-.508 3.416-.964 5.084-.3 1.067-.673 2.1-1.202 3.074-.65 1.192-1.635 1.87-2.992 1.924l-3.832.036c-.636-.017-1.278-.146-1.9-.297-1.192-.3-1.862-1.1-2.06-2.3-.186-1.08-.173-2.187.04-3.264.252-1.23 1-1.96 2.234-2.103.817-.1 1.65-.077 2.476-.1.205-.007.275.098.203.287-.196.53-.236 1.07-.098 1.623.053.207-.023.307-.26.305a7.77 7.77 0 0 0-1.123.053c-.636.086-.96.47-.96 1.112 0 .205.026.416.066.622.103.507.45.78.944.837 1.123.127 2.247.138 3.37-.05.675-.114 1.08-.54 1.16-1.208.152-1.3.155-2.587-.228-3.845-.33-1.092-1.006-1.565-2.134-1.7l-3.36-.54c-1.06-.193-1.7-.887-1.92-1.9-.13-.572-.14-1.17-.214-1.757-.013-.106-.074-.208-.1-.3-.04.1-.106.212-.117.326-.066.68-.053 1.373-.185 2.04-.16.8-.404 1.566-.67 2.33-.185.535-.616.837-1.205.8a37.76 37.76 0 0 1-7.123-1.353l-.64-.207c-.927-.26-1.487-.903-1.74-1.787l-1-3.853-.74-4.3c-.115-.755-.2-1.523-.083-2.293.154-1.112.914-1.903 2.04-1.964l3.558-.062c.127 0 .254.003.373-.026a1.23 1.23 0 0 0 1.01-1.255l-.05-3.036c-.048-1.576.8-2.38 2.156-2.622a10.58 10.58 0 0 1 4.91.26c.933.275 1.467.923 1.715 1.83.058.22.146.3.37.287l2.582.01 3.333.37c.686.095 1.364.25 2.032.42 1.165.298 1.793 1.112 1.962 2.256l.357 3.355.3 5.577.01 2.277zm-4.534-1.155c-.02-.666-.07-1.267-.444-1.784a1.66 1.66 0 0 0-2.469-.15c-.364.4-.494.88-.564 1.4-.008.034.106.126.16.126l.8-.053c.768.007 1.523.113 2.25.393.066.026.136.04.265.077zM8.787 1.154a3.82 3.82 0 0 0-.278 1.592l.05 2.934c.005.357-.075.45-.433.45L5.1 6.156c-.583 0-1.143.1-1.554.278l5.2-5.332c.02.013.04.033.06.053z' />
</svg>
)
}
@@ -2538,7 +2519,7 @@ export function TelegramIcon(props: SVGProps<SVGSVGElement>) {
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
viewBox='1 1 22 22'
width='24'
height='24'
fill='none'
@@ -3635,26 +3616,200 @@ export function HunterIOIcon(props: SVGProps<SVGSVGElement>) {
}
export function MicrosoftOneDriveIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
return (
<svg {...props} fill='currentColor' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'>
<g>
<path
d='M12.2,11.19l.00031-.0011,6.72,4.02,4-1.68.18A6.48,6.48,0,0,1,25.5,13c.14764,0,.29358.1.44.02a10,10,0,0,0-18.04-3.01C7.93,10,7.97,10,8,10A7.96,7.96,0,0,1,12.2,11.19Z'
fill='#0364b8'
/>
<path
d='M12.2,11.19l-.31A7.96,7.96,0,0,0,8,10c-.0343,0-.6805-.10223A8,8,0,0,0,1.44,22.57l5.92-2.49,2.63-1.11,5.86-2.47,3.06-1.29Z'
fill='#0078d4'
/>
<path
d='M25.94,13.02C25.79,13.01,25.65,13,25.5,13a6.48,6.48,0,0,0-2.58.53l-.00018-.00068-4,1.68,1.16.7L23.89,18.19l1.66.99,5.68,3.4a6.5,6.5,0,0,0-5.28-9.57Z'
fill='#1490df'
/>
<path
d='M25.55,19.18,23.89,18.19l-3.8-2.28-1.16-.69528L15.86,16.5,9.99,18.97,7.36,20.08l-5.92,2.49A7.99,7.99,0,0,0,8,26H25.5a6.5,6.5,0,0,0,5.72-3.42Z'
fill='#28a8ea'
/>
</g>
<svg {...props} viewBox='35.98 139.2 648.03 430.85' xmlns='http://www.w3.org/2000/svg'>
<defs>
<radialGradient
id={`onedrive_radial0_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(130.864814,156.804864,-260.089994,217.063603,48.669602,228.766494)'
>
<stop offset='0' stopColor='rgb(28.235294%,58.039216%,99.607843%)' stopOpacity={1} />
<stop
offset='0.695072'
stopColor='rgb(3.529412%,20.392157%,70.196078%)'
stopOpacity={1}
/>
</radialGradient>
<radialGradient
id={`onedrive_radial1_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-575.289668,663.594003,-491.728488,-426.294267,596.956501,-6.380235)'
>
<stop
offset='0.165327'
stopColor='rgb(13.72549%,75.294118%,99.607843%)'
stopOpacity={1}
/>
<stop offset='0.534' stopColor='rgb(10.980392%,56.862745%,100%)' stopOpacity={1} />
</radialGradient>
<radialGradient
id={`onedrive_radial2_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-136.753383,-114.806698,262.816935,-313.057562,181.196995,240.395994)'
>
<stop offset='0' stopColor='rgb(100%,100%,100%)' stopOpacity={0.4} />
<stop offset='0.660528' stopColor='rgb(67.843137%,75.294118%,100%)' stopOpacity={0} />
</radialGradient>
<radialGradient
id={`onedrive_radial3_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-153.638428,-130.000063,197.433014,-233.332948,375.353994,451.43549)'
>
<stop offset='0' stopColor='rgb(1.176471%,22.745098%,80%)' stopOpacity={1} />
<stop offset='1' stopColor='rgb(21.176471%,55.686275%,100%)' stopOpacity={0} />
</radialGradient>
<radialGradient
id={`onedrive_radial4_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(175.585899,405.198026,-437.434522,189.555055,169.378495,125.589294)'
>
<stop
offset='0.592618'
stopColor='rgb(20.392157%,39.215686%,89.019608%)'
stopOpacity={0}
/>
<stop offset='1' stopColor='rgb(1.176471%,22.745098%,80%)' stopOpacity={0.6} />
</radialGradient>
<radialGradient
id={`onedrive_radial5_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-459.329491,459.329491,-719.614455,-719.614455,589.876499,39.484649)'
>
<stop
offset='0'
stopColor='rgb(29.411765%,99.215686%,90.980392%)'
stopOpacity={0.898039}
/>
<stop
offset='0.543937'
stopColor='rgb(29.411765%,99.215686%,90.980392%)'
stopOpacity={0}
/>
</radialGradient>
<linearGradient
id={`onedrive_linear0_${id}`}
gradientUnits='userSpaceOnUse'
x1='29.999701'
y1='37.9823'
x2='29.999701'
y2='18.398199'
gradientTransform='matrix(15,0,0,15,0,0)'
>
<stop offset='0' stopColor='rgb(0%,52.54902%,100%)' stopOpacity={1} />
<stop offset='0.49' stopColor='rgb(0%,73.333333%,100%)' stopOpacity={1} />
</linearGradient>
<radialGradient
id={`onedrive_radial6_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(273.622108,108.513684,-205.488428,518.148261,296.488495,307.441492)'
>
<stop offset='0' stopColor='rgb(100%,100%,100%)' stopOpacity={0.4} />
<stop offset='0.785262' stopColor='rgb(100%,100%,100%)' stopOpacity={0} />
</radialGradient>
<radialGradient
id={`onedrive_radial7_${id}`}
gradientUnits='userSpaceOnUse'
cx='0'
cy='0'
fx='0'
fy='0'
r='1'
gradientTransform='matrix(-305.683909,263.459223,-264.352324,-306.720147,674.845505,249.378004)'
>
<stop
offset='0'
stopColor='rgb(29.411765%,99.215686%,90.980392%)'
stopOpacity={0.898039}
/>
<stop
offset='0.584724'
stopColor='rgb(29.411765%,99.215686%,90.980392%)'
stopOpacity={0}
/>
</radialGradient>
</defs>
<path
fill={`url(#onedrive_radial0_${id})`}
fillRule='nonzero'
d='M 215.078125 205.089844 C 116.011719 205.09375 41.957031 286.1875 36.382812 376.527344 C 39.835938 395.992188 51.175781 434.429688 68.941406 432.457031 C 91.144531 429.988281 147.066406 432.457031 194.765625 346.105469 C 229.609375 283.027344 301.285156 205.085938 215.078125 205.089844 Z M 215.078125 205.089844 '
/>
<path
fill={`url(#onedrive_radial1_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_radial2_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_radial3_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_radial4_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_radial5_${id})`}
fillRule='nonzero'
d='M 192.171875 238.8125 C 158.871094 291.535156 114.042969 367.085938 98.914062 390.859375 C 80.929688 419.121094 33.304688 407.113281 37.25 366.609375 C 36.863281 369.894531 36.5625 373.210938 36.355469 376.546875 C 29.84375 481.933594 113.398438 569.453125 217.375 569.453125 C 331.96875 569.453125 605.269531 426.671875 577.609375 283.609375 C 548.457031 199.519531 466.523438 139.203125 373.664062 139.203125 C 280.808594 139.203125 221.296875 192.699219 192.171875 238.8125 Z M 192.171875 238.8125 '
/>
<path
fill={`url(#onedrive_linear0_${id})`}
fillRule='nonzero'
d='M 215.699219 569.496094 C 215.699219 569.496094 489.320312 570.035156 535.734375 570.035156 C 619.960938 570.035156 684 501.273438 684 421.03125 C 684 340.789062 618.671875 272.445312 535.734375 272.445312 C 452.792969 272.445312 405.027344 334.492188 369.152344 402.226562 C 327.117188 481.59375 273.488281 568.546875 215.699219 569.496094 Z M 215.699219 569.496094 '
/>
<path
fill={`url(#onedrive_radial6_${id})`}
fillRule='nonzero'
d='M 215.699219 569.496094 C 215.699219 569.496094 489.320312 570.035156 535.734375 570.035156 C 619.960938 570.035156 684 501.273438 684 421.03125 C 684 340.789062 618.671875 272.445312 535.734375 272.445312 C 452.792969 272.445312 405.027344 334.492188 369.152344 402.226562 C 327.117188 481.59375 273.488281 568.546875 215.699219 569.496094 Z M 215.699219 569.496094 '
/>
<path
fill={`url(#onedrive_radial7_${id})`}
fillRule='nonzero'
d='M 215.699219 569.496094 C 215.699219 569.496094 489.320312 570.035156 535.734375 570.035156 C 619.960938 570.035156 684 501.273438 684 421.03125 C 684 340.789062 618.671875 272.445312 535.734375 272.445312 C 452.792969 272.445312 405.027344 334.492188 369.152344 402.226562 C 327.117188 481.59375 273.488281 568.546875 215.699219 569.496094 Z M 215.699219 569.496094 '
/>
</svg>
)
}
@@ -5882,12 +6037,22 @@ export function GreenhouseIcon(props: SVGProps<SVGSVGElement>) {
export function GreptileIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
<svg {...props} viewBox='0 0 367 420' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
clipRule='evenodd'
fillRule='evenodd'
fill='#44A775'
d='M3.353.004a6.074 6.074 0 01-.265.045C2.63.12 2.092.348 1.71.633 1.426.846.717 1.575.557 1.819a3.359 3.359 0 00-.23 3.296c.154.322.35.59.71.972.187.198.434.486.55.64a6.629 6.629 0 011.305 3.546c.01.138.035 1.607.057 3.264.043 3.273.038 3.18.203 3.485.266.494.94.79 1.474.648.29-.077.463-.204 1.353-.986.957-.84 1.092-.932 1.446-.98.124-.017.631 0 1.66.053 1.513.08 1.622.079 1.85-.016.393-.164.539-.4.661-1.074.247-1.36 1.296-2.56 2.64-3.022.116-.04.373-.104.572-.144.198-.04.426-.102.506-.138.296-.136.515-.424.566-.744.017-.11-.007-.549-.089-1.602-.091-1.179-.107-1.483-.083-1.621.057-.342.139-.46 1.01-1.448.447-.506.85-.976.895-1.043.262-.39.288-.91.068-1.345a1.44 1.44 0 00-.822-.67c-.1-.029-.834-.037-3.544-.038H9.897l-.335-.063c-.958-.179-1.765-.49-2.484-.958-.362-.236-.583-.41-1.018-.804-.408-.37-.59-.502-.921-.67A3.018 3.018 0 003.744.005a3.942 3.942 0 00-.391 0zm15.728 5.858c-.132.049-.217.127-.48.44-.592.707-.74 1.336-.531 2.256.106.466.163.572.361.673.105.054.169.055 2.637.046l2.53-.009.118-.063a.551.551 0 00.095-.895 184.88 184.88 0 00-2.223-1.254c-2.293-1.282-2.281-1.276-2.507-1.194zm-3.216 6.71a9.258 9.258 0 00-1.364.696c-.844.557-1.454 1.36-1.923 2.53-.211.525-.202.75.04.935.111.087 6.478 3.14 6.667 3.198.153.047.27.027.43-.074a.538.538 0 00.24-.434c0-.06-.03-.18-.065-.264-.156-.368-3.098-6.467-3.158-6.545-.168-.222-.394-.232-.867-.042zm-8.48 5.457c-.453.102-.83.32-1.285.745-.296.277-.336.468-.167.798.053.103.61 1.104 1.236 2.224 1.297 2.317 1.22 2.206 1.558 2.202.152-.002.198-.015.296-.084a.662.662 0 00.173-.193c.058-.11.06-.152.08-2.595.018-1.93.015-2.51-.011-2.606a.569.569 0 00-.138-.227c-.09-.091-.14-.112-.406-.176-.582-.138-.992-.165-1.336-.088z'
d='M240.269 49.8154L166.804 115.963L115.966 159.44L181.335 220.585L249.784 162.048L196.78 112.47L253.068 61.7881L362.605 164.246L178.739 321.489L3.14502 157.242L187.011 0L240.269 49.8154Z'
fill='#3D3B4F'
/>
<rect
width='236.453'
height='83.4566'
transform='matrix(0.75471 -0.656059 0 1 188.017 336.544)'
fill='#3D3B4F'
/>
<rect
width='236.453'
height='83.4566'
transform='matrix(0.731354 0.681998 0 1 0 174.962)'
fill='#3D3B4F'
/>
</svg>
)
+38
View File
@@ -0,0 +1,38 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { normalizeSSODomain } from '@/lib/auth/sso/domain'
describe('normalizeSSODomain', () => {
it('lowercases and trims', () => {
expect(normalizeSSODomain(' Company.COM ')).toBe('company.com')
})
it('strips protocol, path, query, and port', () => {
expect(normalizeSSODomain('https://company.com/sso?x=1')).toBe('company.com')
expect(normalizeSSODomain('company.com:8443')).toBe('company.com')
})
it('strips wildcard, leading @, and email local part', () => {
expect(normalizeSSODomain('*.company.com')).toBe('company.com')
expect(normalizeSSODomain('@company.com')).toBe('company.com')
expect(normalizeSSODomain('user@company.com')).toBe('company.com')
})
it('drops a trailing dot', () => {
expect(normalizeSSODomain('company.com.')).toBe('company.com')
})
it('treats casing and formatting variants as the same domain', () => {
expect(normalizeSSODomain('Company.COM')).toBe(normalizeSSODomain('company.com'))
expect(normalizeSSODomain('user@Company.com')).toBe(normalizeSSODomain('company.com'))
})
it('rejects values that are not registrable domains', () => {
expect(normalizeSSODomain('')).toBeNull()
expect(normalizeSSODomain('localhost')).toBeNull()
expect(normalizeSSODomain('not a domain')).toBeNull()
expect(normalizeSSODomain('company')).toBeNull()
})
})
+25
View File
@@ -0,0 +1,25 @@
/**
* Normalizes a user-supplied SSO email domain to a canonical, comparable form:
* strips protocol, path, query, port, a leading wildcard/`@`, an email local
* part, and a trailing dot, then lowercases. Returns `null` for inputs that are
* not a registrable domain (e.g. `example.com`), which callers treat as invalid.
*/
export function normalizeSSODomain(input: string): string | null {
if (typeof input !== 'string') return null
let value = input.trim().toLowerCase()
if (!value) return null
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
value = value.replace(/^\*\./, '').replace(/^@/, '')
value = value.split('/')[0]
value = value.split('?')[0]
value = value.split('@').pop() ?? value
value = value.split(':')[0]
value = value.replace(/\.$/, '')
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value)) return null
if (value.split('.').some((label) => label.length === 0 || label.length > 63)) return null
return value
}
+21 -1
View File
@@ -12,7 +12,10 @@ import {
shouldRecreateExternalWebhookSubscription,
} from '@/lib/webhooks/provider-subscriptions'
import { getProviderHandler } from '@/lib/webhooks/providers'
import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import {
findConflictingWebhookPathOwner,
syncWebhooksForCredentialSet,
} from '@/lib/webhooks/utils.server'
import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
@@ -538,6 +541,23 @@ export async function saveTriggerWebhooksForDeploy({
}
}
const pathConflict = await findConflictingWebhookPathOwner({
path: triggerPath,
workflowId,
})
if (pathConflict) {
logger.warn(
`[${requestId}] Webhook path conflict for "${triggerPath}": already owned by workflow ${pathConflict}`
)
return {
success: false,
error: {
message: `Webhook path "${triggerPath}" is already in use. Choose a different path.`,
status: 409,
},
}
}
webhookConfigs.set(block.id, { provider, providerConfig, triggerPath, triggerDef })
if (providerConfig.credentialSetId) {
+95 -15
View File
@@ -10,23 +10,36 @@ import {
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGenerateId, mockEnqueue, mockGetJobQueue, mockShouldExecuteInline } = vi.hoisted(
() => ({
mockGenerateId: vi.fn(),
mockEnqueue: vi.fn(),
mockGetJobQueue: vi.fn(),
mockShouldExecuteInline: vi.fn(),
})
)
const {
mockGenerateId,
mockEnqueue,
mockGetJobQueue,
mockShouldExecuteInline,
mockWebhookLookupResult,
} = vi.hoisted(() => ({
mockGenerateId: vi.fn(),
mockEnqueue: vi.fn(),
mockGetJobQueue: vi.fn(),
mockShouldExecuteInline: vi.fn(),
mockWebhookLookupResult: { rows: [] as Array<{ webhook: any; workflow: any }> },
}))
const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution
vi.mock('@sim/db', () => ({
db: {},
webhook: {},
workflow: {},
workflowDeploymentVersion: {},
}))
vi.mock('@sim/db', () => {
const selectChain = {
from: () => selectChain,
innerJoin: () => selectChain,
leftJoin: () => selectChain,
where: () => Promise.resolve(mockWebhookLookupResult.rows),
}
return {
db: { select: () => selectChain },
webhook: {},
workflow: {},
workflowDeploymentVersion: {},
}
})
vi.mock('drizzle-orm', () => ({
and: vi.fn(),
@@ -109,7 +122,74 @@ vi.mock('@/triggers/jira/utils', () => ({
isJiraEventMatch: vi.fn().mockReturnValue(true),
}))
import { checkWebhookPreprocessing, queueWebhookExecution } from '@/lib/webhooks/processor'
import {
checkWebhookPreprocessing,
findAllWebhooksForPath,
queueWebhookExecution,
} from '@/lib/webhooks/processor'
describe('findAllWebhooksForPath cross-tenant collision', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWebhookLookupResult.rows = []
})
const makeRow = (workflowId: string, webhookId: string, createdAt: Date) => ({
webhook: { id: webhookId, workflowId, path: 'shared-path', createdAt },
workflow: { id: workflowId },
})
it('returns all rows when they belong to a single workflow (credential set fan-out)', async () => {
mockWebhookLookupResult.rows = [
makeRow('workflow-1', 'wh-a', new Date('2026-01-01')),
makeRow('workflow-1', 'wh-b', new Date('2026-01-02')),
]
const results = await findAllWebhooksForPath({ requestId: 'req-1', path: 'shared-path' })
expect(results).toHaveLength(2)
expect(results.map((r) => r.webhook.id)).toEqual(['wh-a', 'wh-b'])
})
it('drops foreign rows when a path collides across workflows, keeping the earliest owner', async () => {
const victim = makeRow('victim-workflow', 'victim-wh', new Date('2026-01-01'))
const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01'))
mockWebhookLookupResult.rows = [attacker, victim]
const results = await findAllWebhooksForPath({ requestId: 'req-2', path: 'shared-path' })
expect(results).toHaveLength(1)
expect(results[0].webhook.id).toBe('victim-wh')
expect(results[0].webhook.workflowId).toBe('victim-workflow')
})
it("preserves the owner's full credential-set fan-out while dropping a foreign row", async () => {
const victimA = makeRow('victim-workflow', 'victim-wh-a', new Date('2026-01-01'))
const victimB = makeRow('victim-workflow', 'victim-wh-b', new Date('2026-01-03'))
const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01'))
mockWebhookLookupResult.rows = [victimB, attacker, victimA]
const results = await findAllWebhooksForPath({ requestId: 'req-5', path: 'shared-path' })
expect(results).toHaveLength(2)
expect(results.every((r) => r.webhook.workflowId === 'victim-workflow')).toBe(true)
expect(results.map((r) => r.webhook.id).sort()).toEqual(['victim-wh-a', 'victim-wh-b'])
})
it('returns an empty array when no webhooks match', async () => {
mockWebhookLookupResult.rows = []
const results = await findAllWebhooksForPath({ requestId: 'req-3', path: 'missing' })
expect(results).toEqual([])
})
it('returns an empty array when path is not provided', async () => {
const results = await findAllWebhooksForPath({ requestId: 'req-4' })
expect(results).toEqual([])
})
})
describe('webhook processor execution identity', () => {
beforeEach(() => {
+31 -3
View File
@@ -305,8 +305,12 @@ async function findWebhookAndWorkflow(
}
/**
* Find ALL webhooks matching a path.
* Used for credential sets where multiple webhooks share the same path.
* Finds all webhooks matching a path, scoped to a single workflow.
*
* Legitimate fan-out (credential sets) is always within one workflow, but paths
* are user-controlled and only unique per deployment version, so two tenants can
* register the same path. On collision we keep only the workflow that registered
* the path first, so one tenant can never receive another's webhook deliveries.
*/
export async function findAllWebhooksForPath(
options: WebhookProcessorOptions
@@ -344,7 +348,31 @@ export async function findAllWebhooksForPath(
if (results.length === 0) {
logger.warn(`[${options.requestId}] No active webhooks found for path: ${options.path}`)
} else if (results.length > 1) {
return results
}
const distinctWorkflowIds = new Set(results.map((result) => result.webhook.workflowId))
if (distinctWorkflowIds.size > 1) {
const owner = results.reduce((earliest, candidate) => {
const candidateTime = new Date(candidate.webhook.createdAt).getTime()
const earliestTime = new Date(earliest.webhook.createdAt).getTime()
if (candidateTime !== earliestTime) {
return candidateTime < earliestTime ? candidate : earliest
}
return candidate.webhook.id < earliest.webhook.id ? candidate : earliest
})
const ownerWorkflowId = owner.webhook.workflowId
const ownerResults = results.filter((result) => result.webhook.workflowId === ownerWorkflowId)
logger.error(
`[${options.requestId}] Cross-tenant webhook path collision for path: ${options.path}. Found ${results.length} active webhooks across ${distinctWorkflowIds.size} workflows. Dispatching only to owner workflow ${ownerWorkflowId} and dropping ${results.length - ownerResults.length} foreign webhook(s).`
)
return ownerResults
}
if (results.length > 1) {
logger.info(
`[${options.requestId}] Found ${results.length} webhooks for path: ${options.path} (credential set fan-out)`
)
+37
View File
@@ -10,6 +10,43 @@ import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { getCredentialsForCredentialSet } from '@/app/api/auth/oauth/utils'
import { isPollingWebhookProvider } from '@/triggers/constants'
/**
* Returns the id of a different workflow that already owns an active webhook on
* the given path, or `null` if the path is free or owned by `workflowId`.
*
* Webhook paths are user-controlled and the database only enforces uniqueness
* per deployment version, so this is the single guard against cross-tenant path
* collisions for every webhook creation path. The filter mirrors the runtime
* dispatcher (`findAllWebhooksForPath`): an active, non-archived webhook on a
* non-archived workflow — inactive or archived webhooks never receive
* deliveries, so they must not reserve a path. All matching rows are scanned so
* a same-workflow row can never mask a foreign collision.
*/
export async function findConflictingWebhookPathOwner(params: {
path: string
workflowId: string
tx?: DbOrTx
}): Promise<string | null> {
const { path, workflowId, tx } = params
const dbCtx = tx ?? db
const existing = await dbCtx
.select({ workflowId: webhook.workflowId })
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(
and(
eq(webhook.path, path),
eq(webhook.isActive, true),
isNull(webhook.archivedAt),
isNull(workflow.archivedAt)
)
)
const conflict = existing.find((row) => row.workflowId !== workflowId)
return conflict ? conflict.workflowId : null
}
/**
* Result of syncing webhooks for a credential set
*/