mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
fix: apply object-storage test SSRF validation to all non-super-admins (#10933)
* fix: apply object-storage test SSRF validation to all non-super-admins Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJLqsE5br9r5e7qy8ULwUg * fix: name the job-token case in object-storage test rejections Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJLqsE5br9r5e7qy8ULwUg * fix: run object-storage connection tests with a short-lived user token Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJLqsE5br9r5e7qy8ULwUg * fix: test object-storage resources from the browser, mint a token only for the worker test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJLqsE5br9r5e7qy8ULwUg * fix: resolve variable and resource references before the browser-side object-storage test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJLqsE5br9r5e7qy8ULwUg * fix: bound the browser-side object-storage test to 15s Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJLqsE5br9r5e7qy8ULwUg * fix: explain object-storage test rejections and name the way out Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJLqsE5br9r5e7qy8ULwUg * fix: keep the server-resolved address out of the object-storage test rejection Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJLqsE5br9r5e7qy8ULwUg --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d472193e5b
commit
4fef1195ad
@@ -0,0 +1,97 @@
|
||||
//! `POST /api/settings/test_object_storage_config` runs the probe on the API server and reflects the
|
||||
//! upstream response, so every non-super-admin must be rejected for private/loopback endpoints and
|
||||
//! the Filesystem backend on every deployment (`CLOUD_HOSTED` is unset here), while a super admin's
|
||||
//! Filesystem probe still round-trips. Requires the `parquet` feature, like the route.
|
||||
#![cfg(feature = "parquet")]
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const SUPER_ADMIN_TOKEN: &str = "SECRET_TOKEN";
|
||||
const USER_TOKEN: &str = "SECRET_TOKEN_2";
|
||||
|
||||
async fn test_object_storage(
|
||||
url: &str,
|
||||
token: &str,
|
||||
body: serde_json::Value,
|
||||
) -> anyhow::Result<(u16, String)> {
|
||||
let resp = reqwest::Client::new()
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
Ok((resp.status().as_u16(), resp.text().await?))
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn object_storage_test_is_restricted_for_non_super_admins_off_cloud(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/settings/test_object_storage_config",
|
||||
server.addr.port()
|
||||
);
|
||||
|
||||
// A loopback "S3 endpoint" standing in for an internal service: the probe must be rejected
|
||||
// before the server opens a connection to it.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let internal_port = listener.local_addr()?.port();
|
||||
let connected = Arc::new(AtomicBool::new(false));
|
||||
tokio::spawn({
|
||||
let connected = connected.clone();
|
||||
async move {
|
||||
while listener.accept().await.is_ok() {
|
||||
connected.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
});
|
||||
let internal_s3 = json!({
|
||||
"type": "S3",
|
||||
"bucket": "bucket",
|
||||
"region": "us-east-1",
|
||||
"access_key": "key",
|
||||
"secret_key": "secret",
|
||||
"endpoint": format!("http://127.0.0.1:{internal_port}"),
|
||||
"allow_http": true,
|
||||
"path_style": true,
|
||||
});
|
||||
let (status, body) = test_object_storage(&url, USER_TOKEN, internal_s3).await?;
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"non-super-admin must be rejected for a loopback endpoint (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("requires a super admin"),
|
||||
"unexpected rejection: {body}"
|
||||
);
|
||||
assert!(
|
||||
!connected.load(Ordering::SeqCst),
|
||||
"the server must not connect to the rejected endpoint"
|
||||
);
|
||||
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let filesystem = json!({ "type": "Filesystem", "root_path": tmp.path().to_str().unwrap() });
|
||||
let (status, body) = test_object_storage(&url, USER_TOKEN, filesystem.clone()).await?;
|
||||
assert_eq!(
|
||||
status, 401,
|
||||
"non-super-admin must be rejected for a Filesystem backend (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("requires a super admin"),
|
||||
"unexpected rejection: {body}"
|
||||
);
|
||||
|
||||
// Super admins keep the unrestricted path.
|
||||
let (status, body) = test_object_storage(&url, SUPER_ADMIN_TOKEN, filesystem).await?;
|
||||
assert_eq!(
|
||||
status, 200,
|
||||
"super admin must be able to test a Filesystem backend (got {status}): {body}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -284,15 +284,28 @@ pub async fn test_s3_bucket(
|
||||
use bytes::Bytes;
|
||||
use futures::StreamExt;
|
||||
|
||||
// The probe executes on the API server itself. On multi-tenant Cloud that is a shared control
|
||||
// plane, so we constrain untrusted callers to remove the SSRF / credential-exfiltration /
|
||||
// local-filesystem surface (see validate_object_storage_test). On self-hosted instances the
|
||||
// object store usually lives on the local/private network and all authenticated users are
|
||||
// trusted, so testing there stays unrestricted. Super admins keep the unrestricted path too.
|
||||
// The probe executes on the API server itself and reflects the upstream response into the
|
||||
// error, so any authenticated caller could otherwise use it as an SSRF / port-scan primitive
|
||||
// against the server's network, exfiltrate its ambient credentials, or write to its local
|
||||
// disk (see validate_object_storage_test). That holds on self-hosted instances as much as on
|
||||
// Cloud, so only super admins get the unrestricted path.
|
||||
let is_super_admin = windmill_api_auth::is_super_admin_authed(&db, &authed).await?;
|
||||
let restrict = !is_super_admin && *CLOUD_HOSTED;
|
||||
let restrict = !is_super_admin;
|
||||
if restrict {
|
||||
validate_object_storage_test(&test_s3_bucket).await?;
|
||||
validate_object_storage_test(&test_s3_bucket)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
// A job token never counts as a super admin (it is capped at workspace admin), so
|
||||
// a super admin calling this route from a script is told why rather than that
|
||||
// they lack a privilege they hold.
|
||||
error::Error::NotAuthorized(msg) if authed.job_id.is_some() => {
|
||||
error::Error::NotAuthorized(format!(
|
||||
"{msg} A job token ($WM_TOKEN) is never treated as a super admin; call \
|
||||
this route with a user token instead."
|
||||
))
|
||||
}
|
||||
e => e,
|
||||
})?;
|
||||
}
|
||||
|
||||
let client = build_object_store_from_settings(test_s3_bucket, Some(&db))
|
||||
@@ -355,8 +368,8 @@ pub async fn test_s3_bucket(
|
||||
}
|
||||
}
|
||||
|
||||
// Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller on
|
||||
// Cloud. The probe runs on the shared API server, so without these constraints an authenticated
|
||||
// Hardening for the object-storage connectivity test by an untrusted (non-super-admin) caller.
|
||||
// The probe runs on the API server, so without these constraints an authenticated
|
||||
// user could coerce the server into connecting to arbitrary internal endpoints (SSRF), signing
|
||||
// requests with the instance role (credential exfiltration), or reading/writing the server's local
|
||||
// disk (filesystem object store).
|
||||
@@ -366,6 +379,11 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
|
||||
opt.as_ref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
// Every refusal names the way out: the resource usually works in jobs (workers reach the
|
||||
// endpoint directly), so without it the refusal reads as a broken resource.
|
||||
const ALTERNATIVE: &str =
|
||||
"Ask a super admin to run it, or test the resource from a script, which runs on a worker.";
|
||||
|
||||
// Reject backends that rely on the server's identity or local filesystem, require explicit
|
||||
// credentials for the rest (so the server never falls back to its own ambient credentials), and
|
||||
// resolve the host the client will actually connect to. We derive the *effective* endpoint here
|
||||
@@ -376,20 +394,25 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
|
||||
let effective_endpoint: Option<String> = match settings {
|
||||
ObjectSettings::Filesystem(_) => {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing a local filesystem object store requires a super admin".to_string(),
|
||||
"Testing a local filesystem object store requires a super admin: it runs on the \
|
||||
Windmill server and reads and writes the server's local disk. Ask a super admin \
|
||||
to run it."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
ObjectSettings::AwsOidc(_) => {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing OIDC-based object storage requires a super admin".to_string(),
|
||||
));
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing OIDC-based object storage requires a super admin: it runs on the \
|
||||
Windmill server with the server's own identity. {ALTERNATIVE}"
|
||||
)));
|
||||
}
|
||||
ObjectSettings::S3(s3) => {
|
||||
if !(non_empty(&s3.access_key) && non_empty(&s3.secret_key)) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing S3 storage without explicit credentials requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing S3 storage without an explicit access key and secret key requires a \
|
||||
super admin: it runs on the Windmill server, which would use its own ambient \
|
||||
credentials. {ALTERNATIVE}"
|
||||
)));
|
||||
}
|
||||
let region = s3
|
||||
.region
|
||||
@@ -413,10 +436,11 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
|
||||
}
|
||||
ObjectSettings::Azure(azure) => {
|
||||
if !non_empty(&azure.access_key) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing Azure storage without an explicit access key requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing Azure storage without an explicit access key requires a super admin: \
|
||||
it runs on the Windmill server, which would use its own ambient credentials. \
|
||||
{ALTERNATIVE}"
|
||||
)));
|
||||
}
|
||||
Some(
|
||||
azure
|
||||
@@ -432,10 +456,11 @@ async fn validate_object_storage_test(settings: &ObjectSettings) -> error::Resul
|
||||
// otherwise an untrusted caller could probe with the server's identity (the very
|
||||
// SSRF/credential-exfil this function guards against).
|
||||
if windmill_object_store::gcs_service_account_key_is_blank(&gcs.service_account_key) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing GCS storage without a service account key requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing GCS storage without a service account key requires a super admin: \
|
||||
it runs on the Windmill server, which would use its own ambient credentials. \
|
||||
{ALTERNATIVE}"
|
||||
)));
|
||||
}
|
||||
// The service-account-key JSON can override the data-plane URL (`gcs_base_url`) and the
|
||||
// OAuth token endpoint (`token_uri`); the GCS client connects to whatever they point at.
|
||||
@@ -492,10 +517,15 @@ async fn validate_public_endpoint(endpoint: &str) -> error::Result<()> {
|
||||
// attempts (a name resolving to both a public and a private address).
|
||||
for addr in addrs {
|
||||
if is_forbidden_ip(addr.ip()) {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Testing object storage at a private, loopback, or link-local endpoint requires a super admin"
|
||||
.to_string(),
|
||||
));
|
||||
// The resolved address stays out of the message: it is the server's resolver's
|
||||
// answer, and this message is only ever shown to the caller being constrained.
|
||||
return Err(error::Error::NotAuthorized(format!(
|
||||
"Testing object storage at '{host}', which resolves to a private, loopback, or \
|
||||
link-local address, requires a super admin: this test runs on the Windmill \
|
||||
server, which is not allowed to probe internal addresses for non-super-admins. \
|
||||
Ask a super admin to run it, or test the resource from a script, which runs on \
|
||||
a worker."
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -2008,7 +2038,10 @@ struct CachedResourceType {
|
||||
/// decodes the on-disk cache, where an absent key means "written before the
|
||||
/// column, leave the stored extension alone" and an explicit null means the hub
|
||||
/// dropped it. Plain serde folds both into `None`.
|
||||
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "windmill_common::more_serde::double_option"
|
||||
)]
|
||||
format_extension: Option<Option<String>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@
|
||||
resourceType="s3_bucket"
|
||||
workspaceOverride="admins"
|
||||
buttonTextOverride="Test from a worker"
|
||||
viaWorker
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { type CompletedJob, JobService, type Preview } from '$lib/gen'
|
||||
import {
|
||||
type CompletedJob,
|
||||
JobService,
|
||||
type Preview,
|
||||
ResourceService,
|
||||
SettingService,
|
||||
UserService,
|
||||
VariableService
|
||||
} from '$lib/gen'
|
||||
|
||||
import { Database, Loader2 } from 'lucide-svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
@@ -13,15 +21,57 @@
|
||||
resourceType: string | undefined
|
||||
args?: Record<string, any> | any
|
||||
buttonTextOverride?: string | undefined
|
||||
// Object-storage types only: probe from a preview job (proves a worker reaches the API)
|
||||
// instead of the browser. The job gets a short-lived token minted for the caller, since a
|
||||
// job token is never a super admin, and that token is readable in the job's stored args
|
||||
// until revoked: only use it where the workspace's job readers may hold the caller's rights.
|
||||
viaWorker?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
workspaceOverride = undefined,
|
||||
resourceType,
|
||||
args = {},
|
||||
buttonTextOverride = undefined
|
||||
buttonTextOverride = undefined,
|
||||
viaWorker = false
|
||||
}: Props = $props()
|
||||
|
||||
// Object-storage resource types share one probe, the API's own connectivity test, which runs
|
||||
// on the API server with the caller's privileges. Each type maps its resource to the
|
||||
// ObjectSettings body that route expects.
|
||||
const objectStorageBody: { [key: string]: (args: any) => Record<string, any> } = {
|
||||
s3: (s3) => ({
|
||||
type: 'S3',
|
||||
region: s3.region,
|
||||
bucket: s3.bucket,
|
||||
endpoint: s3.endPoint,
|
||||
port: s3.port,
|
||||
allow_http: !s3.useSSL,
|
||||
access_key: s3.accessKey,
|
||||
secret_key: s3.secretKey,
|
||||
path_style: s3.pathStyle
|
||||
}),
|
||||
azure_blob: (s3) => ({ type: 'Azure', ...s3 }),
|
||||
s3_bucket: (bucket) => bucket
|
||||
}
|
||||
|
||||
const OBJECT_STORAGE_TEST_SCRIPT = `
|
||||
export async function main(bucket: any, api_token: string) {
|
||||
const res = await fetch(process.env.BASE_URL + '/api/settings/test_object_storage_config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + api_token,
|
||||
},
|
||||
body: JSON.stringify(bucket),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text())
|
||||
}
|
||||
return await res.text()
|
||||
}
|
||||
`
|
||||
|
||||
const scripts: {
|
||||
[key: string]: {
|
||||
code: string
|
||||
@@ -68,71 +118,18 @@
|
||||
argName: 'database'
|
||||
},
|
||||
s3: {
|
||||
code: `
|
||||
import * as wmill from "windmill-client"
|
||||
|
||||
type S3 = object
|
||||
|
||||
export async function main(s3: S3) {
|
||||
return fetch(process.env["BASE_URL"] + '/api/settings/test_object_storage_config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + process.env["WM_TOKEN"],
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "S3",
|
||||
region: s3.region,
|
||||
bucket: s3.bucket,
|
||||
endpoint: s3.endPoint,
|
||||
port: s3.port,
|
||||
allow_http: !s3.useSSL,
|
||||
access_key: s3.accessKey,
|
||||
secret_key: s3.secretKey,
|
||||
path_style: s3.pathStyle,
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text())
|
||||
}
|
||||
return res.text()
|
||||
})
|
||||
}
|
||||
`,
|
||||
code: OBJECT_STORAGE_TEST_SCRIPT,
|
||||
lang: 'bun',
|
||||
argName: 's3',
|
||||
argName: 'bucket',
|
||||
tooltip:
|
||||
'The storage operations of this test run on the Windmill server (the API process), not on the worker. If no access key/secret key is set, the ambient AWS credentials of the server (environment variables, instance role) are used — scripts using this resource directly through an S3 SDK resolve credentials on the worker instead, so results may differ.'
|
||||
'The storage operations of this test run on the Windmill server (the API process) with your permissions, not on the worker. Non-super-admins can only test public endpoints with an explicit access key and secret key; super admins can also test private endpoints and rely on the ambient AWS credentials of the server (environment variables, instance role). Scripts using this resource directly through an S3 SDK resolve credentials on the worker instead, so results may differ.'
|
||||
},
|
||||
azure_blob: {
|
||||
code: `
|
||||
import * as wmill from "windmill-client"
|
||||
|
||||
type S3 = object
|
||||
|
||||
export async function main(s3: S3) {
|
||||
return fetch(process.env["BASE_URL"] + '/api/settings/test_object_storage_config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + process.env["WM_TOKEN"],
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "Azure",
|
||||
...s3
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text())
|
||||
}
|
||||
return res.text()
|
||||
})
|
||||
}
|
||||
`,
|
||||
code: OBJECT_STORAGE_TEST_SCRIPT,
|
||||
lang: 'bun',
|
||||
argName: 's3',
|
||||
argName: 'bucket',
|
||||
tooltip:
|
||||
'The storage operations of this test run on the Windmill server (the API process), not on the worker.'
|
||||
'The storage operations of this test run on the Windmill server (the API process) with your permissions, not on the worker. Non-super-admins can only test public endpoints with an explicit access key.'
|
||||
},
|
||||
graphql: {
|
||||
code: '{ __typename }',
|
||||
@@ -158,61 +155,163 @@ export async function main(s3: S3) {
|
||||
}
|
||||
},
|
||||
s3_bucket: {
|
||||
code: `
|
||||
|
||||
const process = require('process');
|
||||
|
||||
export async function main(bucket: any) {
|
||||
const req = await fetch(process.env.BASE_URL + '/api/settings/test_object_storage_config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + process.env.WM_TOKEN,
|
||||
},
|
||||
body: JSON.stringify(bucket),
|
||||
});
|
||||
if (!req.ok) {
|
||||
throw new Error(await req.text());
|
||||
}
|
||||
return await req.text();
|
||||
}
|
||||
`,
|
||||
code: OBJECT_STORAGE_TEST_SCRIPT,
|
||||
lang: 'bun',
|
||||
argName: 'bucket',
|
||||
tooltip:
|
||||
"The storage operations of this test run on the Windmill server (the API process). If no credentials are configured, the server's ambient credentials for the configured provider (environment variables, instance role) are used."
|
||||
"The storage operations of this test run on the Windmill server (the API process) with your permissions. Non-super-admins can only test public endpoints with explicit credentials; super admins can also test private endpoints and rely on the server's ambient credentials for the configured provider (environment variables, instance role)."
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false)
|
||||
|
||||
// The token is revoked as soon as the job settles; the expiry only covers a browser that
|
||||
// goes away mid-test. It is readable in the job's stored args until then, hence the short life.
|
||||
const API_TOKEN_TTL_MS = 60_000
|
||||
// Tokens are addressed by their first 10 characters (TOKEN_PREFIX_LEN on the backend).
|
||||
const API_TOKEN_PREFIX_LEN = 10
|
||||
|
||||
async function mintApiToken(): Promise<string> {
|
||||
return await UserService.createToken({
|
||||
requestBody: {
|
||||
label: `test connection: ${resourceType}`,
|
||||
expiration: new Date(Date.now() + API_TOKEN_TTL_MS).toISOString(),
|
||||
scopes: ['settings:write']
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function revokeApiToken(token: string | undefined) {
|
||||
if (!token) return
|
||||
try {
|
||||
await UserService.deleteToken({ tokenPrefix: token.slice(0, API_TOKEN_PREFIX_LEN) })
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A preview job gets its arguments interpolated on the worker: `$var:`, `$jsonvar:` and
|
||||
// `$res:` references are replaced with the job's privileges before the script runs. The
|
||||
// browser path has to do the same with the caller's session, or a secret stored as a linked
|
||||
// variable (what the "Add resource" drawer saves) is sent verbatim as the credential.
|
||||
async function resolveReferences(value: any, workspace: string): Promise<any> {
|
||||
if (typeof value === 'string') {
|
||||
if (value.startsWith('$var:')) {
|
||||
return await VariableService.getVariableValue({
|
||||
workspace,
|
||||
path: value.slice('$var:'.length)
|
||||
})
|
||||
}
|
||||
if (value.startsWith('$jsonvar:')) {
|
||||
return JSON.parse(
|
||||
await VariableService.getVariableValue({
|
||||
workspace,
|
||||
path: value.slice('$jsonvar:'.length)
|
||||
})
|
||||
)
|
||||
}
|
||||
if (value.startsWith('$res:')) {
|
||||
return await ResourceService.getResourceValueInterpolated({
|
||||
workspace,
|
||||
path: value.slice('$res:'.length)
|
||||
})
|
||||
}
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return await Promise.all(value.map((v) => resolveReferences(v, workspace)))
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const resolved: Record<string, any> = {}
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
resolved[key] = await resolveReferences(v, workspace)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// The route bounds the probe only for non-super-admins; a super admin's probe against an
|
||||
// endpoint that accepts the connection and never answers would otherwise spin here forever.
|
||||
const BROWSER_TEST_TIMEOUT_MS = 15_000
|
||||
|
||||
async function testObjectStorageFromBrowser(body: Record<string, any>, workspace: string) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
try {
|
||||
const request = SettingService.testObjectStorageConfig({
|
||||
requestBody: await resolveReferences(body, workspace)
|
||||
})
|
||||
await Promise.race([
|
||||
request,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
request.cancel()
|
||||
reject(
|
||||
new Error(
|
||||
`no answer from the storage endpoint after ${BROWSER_TEST_TIMEOUT_MS / 1000}s`
|
||||
)
|
||||
)
|
||||
}, BROWSER_TEST_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
sendUserToast('Connection successful', false)
|
||||
} catch (err: any) {
|
||||
sendUserToast('Connection error: ' + (err?.body ?? err?.message ?? err), true)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!resourceType) return
|
||||
loading = true
|
||||
|
||||
const resourceScript = scripts[resourceType]
|
||||
const workspace = workspaceOverride ?? $workspaceStore!
|
||||
const objectStorageArgs: Record<string, any> | undefined =
|
||||
resourceType in objectStorageBody ? objectStorageBody[resourceType](args) : undefined
|
||||
|
||||
const job = await JobService.runScriptPreview({
|
||||
workspace: workspaceOverride ?? $workspaceStore!,
|
||||
requestBody: {
|
||||
path: `testConnection: ${resourceType}`,
|
||||
language: resourceScript.lang as Preview['language'],
|
||||
content: resourceScript.code,
|
||||
args: {
|
||||
[resourceScript.argName]: args
|
||||
}
|
||||
if (objectStorageArgs && !viaWorker) {
|
||||
await testObjectStorageFromBrowser(objectStorageArgs, workspace)
|
||||
return
|
||||
}
|
||||
|
||||
let apiToken: string | undefined = undefined
|
||||
let job: string
|
||||
try {
|
||||
if (objectStorageArgs) {
|
||||
apiToken = await mintApiToken()
|
||||
}
|
||||
})
|
||||
job = await JobService.runScriptPreview({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path: `testConnection: ${resourceType}`,
|
||||
language: resourceScript.lang as Preview['language'],
|
||||
content: resourceScript.code,
|
||||
args: objectStorageArgs
|
||||
? { bucket: objectStorageArgs, api_token: apiToken }
|
||||
: { [resourceScript.argName]: args }
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
loading = false
|
||||
await revokeApiToken(apiToken)
|
||||
sendUserToast('Connection error: ' + (err?.body ?? err?.message ?? err), true)
|
||||
return
|
||||
}
|
||||
|
||||
tryEvery({
|
||||
tryCode: async () => {
|
||||
let testResult = await JobService.getCompletedJob({
|
||||
workspace: workspaceOverride ?? $workspaceStore!,
|
||||
workspace,
|
||||
id: job
|
||||
})
|
||||
if (resourceScript.additionalCheck) {
|
||||
testResult = resourceScript.additionalCheck(testResult)
|
||||
}
|
||||
loading = false
|
||||
revokeApiToken(apiToken)
|
||||
sendUserToast(
|
||||
testResult.success
|
||||
? 'Connection successful'
|
||||
@@ -222,13 +321,14 @@ export async function main(bucket: any) {
|
||||
},
|
||||
timeoutCode: async () => {
|
||||
loading = false
|
||||
revokeApiToken(apiToken)
|
||||
sendUserToast(
|
||||
'Connection did not resolve after 5s or job did not start. Do you have native workers or a worker group listening to the proper tag available?',
|
||||
true
|
||||
)
|
||||
try {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace: workspaceOverride ?? $workspaceStore!,
|
||||
workspace,
|
||||
id: job,
|
||||
requestBody: {
|
||||
reason:
|
||||
|
||||
Reference in New Issue
Block a user