fix: preserve msteams attachment auth fallback retries (#25955) (thanks @bmendonca3)

This commit is contained in:
Peter Steinberger
2026-03-02 20:37:39 +00:00
parent a8ea658383
commit 931386a5d4
3 changed files with 43 additions and 4 deletions

View File

@@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
- MSTeams/attachment auth fallback: keep guarded redirect ownership in dispatcher-mode fetch paths while preserving scope fallback retries for transient non-auth failures during media downloads. (#25955) Thanks @bmendonca3.
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.

View File

@@ -164,7 +164,13 @@ const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST
const PNG_BUFFER = Buffer.from("png");
const PNG_BASE64 = PNG_BUFFER.toString("base64");
const PDF_BUFFER = Buffer.from("pdf");
const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") });
const createTokenProvider = (
tokenOrResolver: string | ((scope: string) => string | Promise<string>) = "token",
) => ({
getAccessToken: vi.fn(async (scope: string) =>
typeof tokenOrResolver === "function" ? await tokenOrResolver(scope) : tokenOrResolver,
),
});
const asSingleItemArray = <T>(value: T) => [value];
const withLabel = <T extends object>(label: string, fields: T): T & LabeledCase => ({
label,
@@ -742,6 +748,31 @@ describe("msteams attachments", () => {
expect(fetchMock.mock.calls.map(([calledUrl]) => String(calledUrl))).toContain(redirectedUrl);
});
it("continues scope fallback after non-auth failure and succeeds on later scope", async () => {
let authAttempt = 0;
const tokenProvider = createTokenProvider((scope) => `token:${scope}`);
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const auth = new Headers(opts?.headers).get("Authorization");
if (!auth) {
return createTextResponse("unauthorized", 401);
}
authAttempt += 1;
if (authAttempt === 1) {
return createTextResponse("upstream transient", 500);
}
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
});
const media = await downloadAttachmentsWithFetch(
createImageAttachments(TEST_URL_IMAGE),
fetchMock,
{ tokenProvider, authAllowHosts: [TEST_HOST] },
);
expectAttachmentMediaLength(media, 1);
expect(tokenProvider.getAccessToken).toHaveBeenCalledTimes(2);
});
it("skips urls outside the allowlist", async () => {
const fetchMock = vi.fn();
const media = await downloadAttachmentsWithFetch(

View File

@@ -86,6 +86,10 @@ function scopeCandidatesForUrl(url: string): string[] {
}
}
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
async function fetchWithAuthFallback(params: {
url: string;
tokenProvider?: MSTeamsAccessTokenProvider;
@@ -132,11 +136,14 @@ async function fetchWithAuthFallback(params: {
if (authAttempt.ok) {
return authAttempt;
}
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
// Non-auth failures (including redirects in guarded fetch mode) should
// be handled by the caller's redirect/error policy.
if (isRedirectStatus(authAttempt.status)) {
// Redirects in guarded fetch mode must propagate to the outer guard.
return authAttempt;
}
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
// Preserve legacy scope fallback semantics for non-auth failures.
continue;
}
} catch {
// Try the next scope.
}