diff --git a/README.md b/README.md index 33da9a3..6c41f74 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ [![StepSecurity Maintained Action](https://raw.githubusercontent.com/step-security/maintained-actions-assets/main/assets/maintained-action-banner.png)](https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions) -# Github Action for sending message (and reactions/threads/update/blocks) to Slack +# Github Action for sending message (and reactions/threads/update/blocks/file uploads) to Slack — With support for Slack's optional arguments ![](https://img.shields.io/github/release/step-security/github-actions-slack/all.svg) ![](https://snyk.io/test/github/step-security/github-actions-slack/badge.svg) -This Action allows you to send messages (and reactions/threads/update/blocks) to Slack from your Github Actions. Supports Slack's required arguments as well as all the optional once. It's JavaScript-based and thus fast to run. +This Action allows you to send messages (and reactions/threads/update/blocks/file uploads) to Slack from your Github Actions. Supports Slack's required arguments as well as all the optional once. It's JavaScript-based and thus fast to run. -The goal is to have zero npm/yarn dependencies except `@actions/core` which is required for an action to work. +The goal is to have zero npm production dependencies except `@actions/core` which is required for an action to work. ![Slack result](./images/slack-result.png "Slack result") @@ -39,6 +39,9 @@ This action supports: - 3. Send reaction on sent messages
+- 4. Upload files
+ + ## 1. Send messages to Slack **Required: Github Repository Secret:** @@ -303,6 +306,66 @@ For some examples, please see: - [.github/workflows/11-slack-message-blocks.yml](.github/workflows/11-slack-message-blocks.yml) - [.github/workflows/12-slack-message-blocks-update.yml](.github/workflows/12-slack-message-blocks-update.yml) +## 6. Upload files + +Upload files to a Slack channel using the `upload-file` function. + +**Required: Github Action Parameters:** + +- `slack-bot-user-oauth-access-token` - `SLACK_BOT_USER_OAUTH_ACCESS_TOKEN` secret + +- `slack-channel` - The channel where you want to upload the file + +- `slack-upload-file-path` - Path to the file to upload + +**Optional: Github Action Parameters:** + +- `slack-upload-filename` - Override the filename shown in Slack + +- `slack-upload-file-title` - Title of the file + +- `slack-upload-initial-comment` - Initial comment to add alongside the file + +**Upload security checks:** + +- Uploads are limited to `https` and allowlisted Slack upload hosts only. Currently the upload host must be `files.slack.com`. + +- Uploads are limited to 10 MB per file. + +- The upload path must point to a file with an allowlisted extension. Current allowlist: `.txt`, `.log`, `.json`, `.yaml`, `.yml`, `.xml`, `.pdf`, `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp`, `.svg`, `.tif`, `.tiff`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`. + +### Sample Action file + +``` +name: slack-upload-file + +on: [push] + +jobs: + slack-upload-file: + runs-on: ubuntu-24.04 + name: Uploads a file to Slack + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Upload File to Slack + uses: step-security/github-actions-slack@v3 + id: upload-file + with: + slack-function: upload-file + slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + slack-channel: CPPUV5KU0 + slack-upload-file-path: path/to/file.png + slack-upload-file-title: My File + slack-upload-initial-comment: Here is the file! + - name: Result from "Upload File" + run: echo "${{ steps.upload-file.outputs.slack-result }}" +``` + +![Slack result](./images/upload.png "Slack result") + ## How to setup your first Github Action in your repository that will call this Action ### 1. Create a Slack bot @@ -413,12 +476,12 @@ The source code moved from CommonJS (`require`) to ES Modules (`import`/`export` ## Development and testing -See package.json for `yarn lint`, `yarn test`, etc. +See package.json for commands. Remember to create the dist with `npm run build`. To run local integration test (from this repository): ``` -env BOT_USER_OAUTH_ACCESS_TOKEN= CHANNEL= node integration-test/end-to-end.js +env BOT_USER_OAUTH_ACCESS_TOKEN= CHANNEL= node integration-test/end-to-end.js ``` diff --git a/action.yml b/action.yml index d07082e..72c15a3 100644 --- a/action.yml +++ b/action.yml @@ -56,7 +56,19 @@ inputs: description: "https://api.slack.com/methods/chat.postMessage#arg_username" required: false slack-function: - description: send-message (https://api.slack.com/methods/chat.postMessage) or send-reaction (https://api.slack.com/methods/reactions.add) or update-message (https://api.slack.com/methods/chat.update) + description: send-message (https://api.slack.com/methods/chat.postMessage) or send-reaction (https://api.slack.com/methods/reactions.add) or update-message (https://api.slack.com/methods/chat.update) or upload-file (https://api.slack.com/methods/files.upload) + required: false + slack-upload-file-path: + description: "https://api.slack.com/methods/files.upload#arg_file" + required: false + slack-upload-filename: + description: "https://api.slack.com/methods/files.upload#arg_filename" + required: false + slack-upload-file-title: + description: "https://api.slack.com/methods/files.upload#arg_title" + required: false + slack-upload-initial-comment: + description: "https://api.slack.com/methods/files.upload#arg_initial_comment" required: false slack-emoji-name: description: "https://api.slack.com/methods/reactions.add#arg_name" diff --git a/dist/index.cjs b/dist/index.cjs index 3363922..491e1b9 100644 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -8875,11 +8875,11 @@ var require_mime_types = __commonJS({ } return exts[0]; } - function lookup(path) { - if (!path || typeof path !== "string") { + function lookup(path2) { + if (!path2 || typeof path2 !== "string") { return false; } - var extension2 = extname("x." + path).toLowerCase().substr(1); + var extension2 = extname("x." + path2).toLowerCase().substr(1); if (!extension2) { return false; } @@ -9984,11 +9984,11 @@ var require_form_data = __commonJS({ "use strict"; var CombinedStream = require_combined_stream(); var util3 = require("util"); - var path = require("path"); + var path2 = require("path"); var http3 = require("http"); var https3 = require("https"); var parseUrl2 = require("url").parse; - var fs4 = require("fs"); + var fs5 = require("fs"); var Stream = require("stream").Stream; var crypto3 = require("crypto"); var mime = require_mime_types(); @@ -10055,7 +10055,7 @@ var require_form_data = __commonJS({ if (value.end != void 0 && value.end != Infinity && value.start != void 0) { callback(null, value.end + 1 - (value.start ? value.start : 0)); } else { - fs4.stat(value.path, function(err, stat2) { + fs5.stat(value.path, function(err, stat2) { if (err) { callback(err); return; @@ -10112,11 +10112,11 @@ var require_form_data = __commonJS({ FormData3.prototype._getContentDisposition = function(value, options) { var filename; if (typeof options.filepath === "string") { - filename = path.normalize(options.filepath).replace(/\\/g, "/"); + filename = path2.normalize(options.filepath).replace(/\\/g, "/"); } else if (options.filename || value && (value.name || value.path)) { - filename = path.basename(options.filename || value && (value.name || value.path)); + filename = path2.basename(options.filename || value && (value.name || value.path)); } else if (value && value.readable && hasOwn(value, "httpVersion")) { - filename = path.basename(value.client._httpMessage.path || ""); + filename = path2.basename(value.client._httpMessage.path || ""); } if (filename) { return 'filename="' + filename + '"'; @@ -12615,14 +12615,14 @@ var require_util = __commonJS({ } const port = url2.port != null ? url2.port : url2.protocol === "https:" ? 443 : 80; let origin2 = url2.origin != null ? url2.origin : `${url2.protocol || ""}//${url2.hostname || ""}:${port}`; - let path = url2.path != null ? url2.path : `${url2.pathname || ""}${url2.search || ""}`; + let path2 = url2.path != null ? url2.path : `${url2.pathname || ""}${url2.search || ""}`; if (origin2[origin2.length - 1] === "/") { origin2 = origin2.slice(0, origin2.length - 1); } - if (path && path[0] !== "/") { - path = `/${path}`; + if (path2 && path2[0] !== "/") { + path2 = `/${path2}`; } - return new URL(`${origin2}${path}`); + return new URL(`${origin2}${path2}`); } if (!isHttpOrHttpsPrefixed(url2.origin || url2.protocol)) { throw new InvalidArgumentError("Invalid URL protocol: the URL must start with `http:` or `https:`."); @@ -13073,39 +13073,39 @@ var require_diagnostics = __commonJS({ }); diagnosticsChannel.channel("undici:client:sendHeaders").subscribe((evt) => { const { - request: { method, path, origin: origin2 } + request: { method, path: path2, origin: origin2 } } = evt; - debuglog("sending request to %s %s/%s", method, origin2, path); + debuglog("sending request to %s %s/%s", method, origin2, path2); }); diagnosticsChannel.channel("undici:request:headers").subscribe((evt) => { const { - request: { method, path, origin: origin2 }, + request: { method, path: path2, origin: origin2 }, response: { statusCode } } = evt; debuglog( "received response to %s %s/%s - HTTP %d", method, origin2, - path, + path2, statusCode ); }); diagnosticsChannel.channel("undici:request:trailers").subscribe((evt) => { const { - request: { method, path, origin: origin2 } + request: { method, path: path2, origin: origin2 } } = evt; - debuglog("trailers received from %s %s/%s", method, origin2, path); + debuglog("trailers received from %s %s/%s", method, origin2, path2); }); diagnosticsChannel.channel("undici:request:error").subscribe((evt) => { const { - request: { method, path, origin: origin2 }, + request: { method, path: path2, origin: origin2 }, error: error2 } = evt; debuglog( "request to %s %s/%s errored - %s", method, origin2, - path, + path2, error2.message ); }); @@ -13154,9 +13154,9 @@ var require_diagnostics = __commonJS({ }); diagnosticsChannel.channel("undici:client:sendHeaders").subscribe((evt) => { const { - request: { method, path, origin: origin2 } + request: { method, path: path2, origin: origin2 } } = evt; - debuglog("sending request to %s %s/%s", method, origin2, path); + debuglog("sending request to %s %s/%s", method, origin2, path2); }); } diagnosticsChannel.channel("undici:websocket:open").subscribe((evt) => { @@ -13219,7 +13219,7 @@ var require_request = __commonJS({ var kHandler = /* @__PURE__ */ Symbol("handler"); var Request = class { constructor(origin2, { - path, + path: path2, method, body, headers, @@ -13234,11 +13234,11 @@ var require_request = __commonJS({ expectContinue, servername }, handler) { - if (typeof path !== "string") { + if (typeof path2 !== "string") { throw new InvalidArgumentError("path must be a string"); - } else if (path[0] !== "/" && !(path.startsWith("http://") || path.startsWith("https://")) && method !== "CONNECT") { + } else if (path2[0] !== "/" && !(path2.startsWith("http://") || path2.startsWith("https://")) && method !== "CONNECT") { throw new InvalidArgumentError("path must be an absolute URL or start with a slash"); - } else if (invalidPathRegex.test(path)) { + } else if (invalidPathRegex.test(path2)) { throw new InvalidArgumentError("invalid request path"); } if (typeof method !== "string") { @@ -13304,7 +13304,7 @@ var require_request = __commonJS({ this.completed = false; this.aborted = false; this.upgrade = upgrade || null; - this.path = query ? buildURL2(path, query) : path; + this.path = query ? buildURL2(path2, query) : path2; this.origin = origin2; this.idempotent = idempotent == null ? method === "HEAD" || method === "GET" : idempotent; this.blocking = blocking == null ? false : blocking; @@ -17830,7 +17830,7 @@ var require_client_h1 = __commonJS({ return method !== "GET" && method !== "HEAD" && method !== "OPTIONS" && method !== "TRACE" && method !== "CONNECT"; } function writeH1(client, request) { - const { method, path, host, upgrade, blocking, reset } = request; + const { method, path: path2, host, upgrade, blocking, reset } = request; let { body, headers, contentLength } = request; const expectsPayload = method === "PUT" || method === "POST" || method === "PATCH" || method === "QUERY" || method === "PROPFIND" || method === "PROPPATCH"; if (util3.isFormDataLike(body)) { @@ -17896,7 +17896,7 @@ var require_client_h1 = __commonJS({ if (blocking) { socket[kBlocking] = true; } - let header = `${method} ${path} HTTP/1.1\r + let header = `${method} ${path2} HTTP/1.1\r `; if (typeof host === "string") { header += `host: ${host}\r @@ -18422,7 +18422,7 @@ var require_client_h2 = __commonJS({ } function writeH2(client, request) { const session = client[kHTTP2Session]; - const { method, path, host, upgrade, expectContinue, signal, headers: reqHeaders } = request; + const { method, path: path2, host, upgrade, expectContinue, signal, headers: reqHeaders } = request; let { body } = request; if (upgrade) { util3.errorRequest(client, request, new Error("Upgrade not supported for H2")); @@ -18489,7 +18489,7 @@ var require_client_h2 = __commonJS({ }); return true; } - headers[HTTP2_HEADER_PATH] = path; + headers[HTTP2_HEADER_PATH] = path2; headers[HTTP2_HEADER_SCHEME] = "https"; const expectsPayload = method === "PUT" || method === "POST" || method === "PATCH"; if (body && typeof body.read === "function") { @@ -18842,9 +18842,9 @@ var require_redirect_handler = __commonJS({ return this.handler.onHeaders(statusCode, headers, resume, statusText); } const { origin: origin2, pathname, search } = util3.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin))); - const path = search ? `${pathname}${search}` : pathname; + const path2 = search ? `${pathname}${search}` : pathname; this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin2); - this.opts.path = path; + this.opts.path = path2; this.opts.origin = origin2; this.opts.maxRedirections = 0; this.opts.query = null; @@ -20079,10 +20079,10 @@ var require_proxy_agent = __commonJS({ }; const { origin: origin2, - path = "/", + path: path2 = "/", headers = {} } = opts; - opts.path = origin2 + path; + opts.path = origin2 + path2; if (!("host" in headers) && !("Host" in headers)) { const { host } = new URL2(origin2); headers.host = host; @@ -22003,20 +22003,20 @@ var require_mock_utils = __commonJS({ } return true; } - function safeUrl(path) { - if (typeof path !== "string") { - return path; + function safeUrl(path2) { + if (typeof path2 !== "string") { + return path2; } - const pathSegments = path.split("?"); + const pathSegments = path2.split("?"); if (pathSegments.length !== 2) { - return path; + return path2; } const qp = new URLSearchParams(pathSegments.pop()); qp.sort(); return [...pathSegments, qp.toString()].join("?"); } - function matchKey(mockDispatch2, { path, method, body, headers }) { - const pathMatch = matchValue(mockDispatch2.path, path); + function matchKey(mockDispatch2, { path: path2, method, body, headers }) { + const pathMatch = matchValue(mockDispatch2.path, path2); const methodMatch = matchValue(mockDispatch2.method, method); const bodyMatch = typeof mockDispatch2.body !== "undefined" ? matchValue(mockDispatch2.body, body) : true; const headersMatch = matchHeaders(mockDispatch2, headers); @@ -22038,7 +22038,7 @@ var require_mock_utils = __commonJS({ function getMockDispatch(mockDispatches, key) { const basePath = key.query ? buildURL2(key.path, key.query) : key.path; const resolvedPath = typeof basePath === "string" ? safeUrl(basePath) : basePath; - let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path }) => matchValue(safeUrl(path), resolvedPath)); + let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path: path2 }) => matchValue(safeUrl(path2), resolvedPath)); if (matchedMockDispatches.length === 0) { throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`); } @@ -22076,9 +22076,9 @@ var require_mock_utils = __commonJS({ } } function buildKey(opts) { - const { path, method, body, headers, query } = opts; + const { path: path2, method, body, headers, query } = opts; return { - path, + path: path2, method, body, headers, @@ -22541,10 +22541,10 @@ var require_pending_interceptors_formatter = __commonJS({ } format(pendingInterceptors) { const withPrettyHeaders = pendingInterceptors.map( - ({ method, path, data: { statusCode }, persist, times, timesInvoked, origin: origin2 }) => ({ + ({ method, path: path2, data: { statusCode }, persist, times, timesInvoked, origin: origin2 }) => ({ Method: method, Origin: origin2, - Path: path, + Path: path2, "Status code": statusCode, Persistent: persist ? PERSISTENT : NOT_PERSISTENT, Invocations: timesInvoked, @@ -27425,9 +27425,9 @@ var require_util6 = __commonJS({ } } } - function validateCookiePath(path) { - for (let i = 0; i < path.length; ++i) { - const code = path.charCodeAt(i); + function validateCookiePath(path2) { + for (let i = 0; i < path2.length; ++i) { + const code = path2.charCodeAt(i); if (code < 32 || // exclude CTLs (0-31) code === 127 || // DEL code === 59) { @@ -30104,11 +30104,11 @@ var require_undici = __commonJS({ if (typeof opts.path !== "string") { throw new InvalidArgumentError("invalid opts.path"); } - let path = opts.path; + let path2 = opts.path; if (!opts.path.startsWith("/")) { - path = `/${path}`; + path2 = `/${path2}`; } - url2 = new URL(util3.parseOrigin(url2).origin + path); + url2 = new URL(util3.parseOrigin(url2).origin + path2); } else { if (!opts) { opts = typeof url2 === "object" ? url2 : {}; @@ -30699,9 +30699,9 @@ function isVisitable(thing) { function removeBrackets(key) { return utils_default.endsWith(key, "[]") ? key.slice(0, -2) : key; } -function renderKey(path, key, dots) { - if (!path) return key; - return path.concat(key).map(function each(token, i) { +function renderKey(path2, key, dots) { + if (!path2) return key; + return path2.concat(key).map(function each(token, i) { token = removeBrackets(token); return !dots && i ? "[" + token + "]" : token; }).join(dots ? "." : ""); @@ -30754,13 +30754,13 @@ function toFormData(obj, formData, options) { } return value; } - function defaultVisitor(value, key, path) { + function defaultVisitor(value, key, path2) { let arr = value; if (utils_default.isReactNative(formData) && utils_default.isReactNativeBlob(value)) { - formData.append(renderKey(path, key, dots), convertValue(value)); + formData.append(renderKey(path2, key, dots), convertValue(value)); return false; } - if (value && !path && typeof value === "object") { + if (value && !path2 && typeof value === "object") { if (utils_default.endsWith(key, "{}")) { key = metaTokens ? key : key.slice(0, -2); value = JSON.stringify(value); @@ -30779,7 +30779,7 @@ function toFormData(obj, formData, options) { if (isVisitable(value)) { return true; } - formData.append(renderKey(path, key, dots), convertValue(value)); + formData.append(renderKey(path2, key, dots), convertValue(value)); return false; } const stack = []; @@ -30788,16 +30788,16 @@ function toFormData(obj, formData, options) { convertValue, isVisitable }); - function build(value, path) { + function build(value, path2) { if (utils_default.isUndefined(value)) return; if (stack.indexOf(value) !== -1) { - throw Error("Circular reference detected in " + path.join(".")); + throw Error("Circular reference detected in " + path2.join(".")); } stack.push(value); utils_default.forEach(value, function each(el, key) { - const result = !(utils_default.isUndefined(el) || el === null) && visitor.call(formData, el, utils_default.isString(key) ? key.trim() : key, path, exposedHelpers); + const result = !(utils_default.isUndefined(el) || el === null) && visitor.call(formData, el, utils_default.isString(key) ? key.trim() : key, path2, exposedHelpers); if (result === true) { - build(el, path ? path.concat(key) : [key]); + build(el, path2 ? path2.concat(key) : [key]); } }); stack.pop(); @@ -31009,7 +31009,7 @@ var platform_default = { // node_modules/axios/lib/helpers/toURLEncodedForm.js function toURLEncodedForm(data, options) { return toFormData_default(data, new platform_default.classes.URLSearchParams(), { - visitor: function(value, key, path, helpers) { + visitor: function(value, key, path2, helpers) { if (platform_default.isNode && utils_default.isBuffer(value)) { this.append(key, value.toString("base64")); return false; @@ -31039,11 +31039,11 @@ function arrayToObject(arr) { return obj; } function formDataToJSON(formData) { - function buildPath(path, value, target, index) { - let name = path[index++]; + function buildPath(path2, value, target, index) { + let name = path2[index++]; if (name === "__proto__") return true; const isNumericKey = Number.isFinite(+name); - const isLast = index >= path.length; + const isLast = index >= path2.length; name = !name && utils_default.isArray(target) ? target.length : name; if (isLast) { if (utils_default.hasOwnProp(target, name)) { @@ -31056,7 +31056,7 @@ function formDataToJSON(formData) { if (!target[name] || !utils_default.isObject(target[name])) { target[name] = []; } - const result = buildPath(path, value, target[name], index); + const result = buildPath(path2, value, target[name], index); if (result && utils_default.isArray(target[name])) { target[name] = arrayToObject(target[name]); } @@ -32506,9 +32506,9 @@ var http_default = isHttpAdapterSupported && function httpAdapter(config) { auth = urlUsername + ":" + urlPassword; } auth && headers.delete("authorization"); - let path; + let path2; try { - path = buildURL( + path2 = buildURL( parsed.pathname + parsed.search, config.params, config.paramsSerializer @@ -32526,7 +32526,7 @@ var http_default = isHttpAdapterSupported && function httpAdapter(config) { false ); const options = { - path, + path: path2, method, headers: headers.toJSON(), agents: { http: config.httpAgent, https: config.httpsAgent }, @@ -32775,14 +32775,14 @@ var isURLSameOrigin_default = platform_default.hasStandardBrowserEnv ? /* @__PUR var cookies_default = platform_default.hasStandardBrowserEnv ? ( // Standard browser envs support document.cookie { - write(name, value, expires, path, domain, secure, sameSite) { + write(name, value, expires, path2, domain, secure, sameSite) { if (typeof document === "undefined") return; const cookie = [`${name}=${encodeURIComponent(value)}`]; if (utils_default.isNumber(expires)) { cookie.push(`expires=${new Date(expires).toUTCString()}`); } - if (utils_default.isString(path)) { - cookie.push(`path=${path}`); + if (utils_default.isString(path2)) { + cookie.push(`path=${path2}`); } if (utils_default.isString(domain)) { cookie.push(`domain=${domain}`); @@ -34028,7 +34028,7 @@ var { } = axios_default; // src/invoke.js -var import_fs2 = __toESM(require("fs"), 1); +var import_fs3 = __toESM(require("fs"), 1); // node_modules/@actions/core/lib/command.js var os = __toESM(require("os"), 1); @@ -34538,13 +34538,18 @@ var debugExtra = (name, json) => { debug(message); }; +// src/integration/slack-api.js +var import_fs2 = __toESM(require("fs"), 1); +var import_path = __toESM(require("path"), 1); + // src/integration/slack-api-post.js var import_https2 = __toESM(require("https"), 1); -var getOptions = (token, path) => { +var ALLOWED_UPLOAD_HOSTS = /* @__PURE__ */ new Set(["files.slack.com"]); +var getOptions = (token, path2) => { return { hostname: "slack.com", port: 443, - path, + path: path2, method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", @@ -34552,11 +34557,11 @@ var getOptions = (token, path) => { } }; }; -var post = (token, path, message) => { +var post = (token, path2, message) => { return new Promise((resolve, reject) => { const payload = JSON.stringify(message); debugExtra("SLACK POST PAYLOAD", payload); - const options = getOptions(token, path); + const options = getOptions(token, path2); const req = import_https2.default.request(options, (res) => { const chunks = []; res.on("data", (chunk) => { @@ -34585,36 +34590,193 @@ var post = (token, path, message) => { req.end(); }); }; +var postForm = (token, path2, fields) => { + return new Promise((resolve, reject) => { + const payload = new URLSearchParams(fields).toString(); + debugExtra("SLACK POST FORM PAYLOAD", payload); + const options = { + hostname: "slack.com", + port: 443, + path: path2, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": Buffer.byteLength(payload), + Authorization: `Bearer ${token}` + } + }; + const req = import_https2.default.request(options, (res) => { + const chunks = []; + res.on("data", (chunk) => { + chunks.push(chunk); + }); + res.on("end", () => { + const result = Buffer.concat(chunks).toString(); + const response = JSON.parse(result); + let isOk = res.statusCode >= 200 && res.statusCode <= 299; + if (response && response.hasOwnProperty("ok") && response.ok === false) { + isOk = false; + } + resolve({ + statusCode: res.statusCode, + statusMessage: res.statusMessage, + ok: isOk, + result, + response + }); + }); + }); + req.on("error", (error2) => { + reject(error2); + }); + req.write(payload); + req.end(); + }); +}; +var validateUploadUrl = (uploadUrl) => { + const url2 = new URL(uploadUrl); + if (url2.protocol !== "https:") { + throw new Error("Upload URL must use https"); + } + if (!ALLOWED_UPLOAD_HOSTS.has(url2.hostname)) { + throw new Error(`Upload host not allowed: ${url2.hostname}`); + } + return url2; +}; +var postBinary = (uploadUrl, fileContent) => { + return new Promise((resolve, reject) => { + const url2 = validateUploadUrl(uploadUrl); + const options = { + hostname: url2.hostname, + port: 443, + path: url2.pathname + url2.search, + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": fileContent.length + } + }; + debug2("SLACK UPLOAD BINARY to " + url2.hostname + url2.pathname); + const req = import_https2.default.request(options, (res) => { + const chunks = []; + res.on("data", (chunk) => { + chunks.push(chunk); + }); + res.on("end", () => { + resolve({ + statusCode: res.statusCode, + ok: res.statusCode >= 200 && res.statusCode <= 299 + }); + }); + }); + req.on("error", (error2) => { + reject(error2); + }); + req.write(fileContent); + req.end(); + }); +}; // src/integration/slack-api.js +var MAX_UPLOAD_BYTES = 10 * 1024 * 1024; +var ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([ + ".txt", + ".log", + ".json", + ".yaml", + ".yml", + ".xml", + ".pdf", + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".bmp", + ".svg", + ".tif", + ".tiff", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".ppt", + ".pptx" +]); var hasErrors = (res) => !res || !res.ok; var buildErrorMessage = (res) => { return `Error! ${JSON.stringify(res)} (response)`; }; +var validateUploadFile = (filePath) => { + const extension = import_path.default.extname(filePath).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(extension)) { + throw new Error(`File type not allowed: ${extension || ""}`); + } + const stat2 = import_fs2.default.statSync(filePath); + if (!stat2.isFile()) { + throw new Error("Upload path must be a file"); + } + if (stat2.size > MAX_UPLOAD_BYTES) { + throw new Error(`File too large: ${stat2.size}`); + } +}; var apiPostMessage = async (token, message) => { - const path = "/api/chat.postMessage"; - const res = await post(token, path, message); + const path2 = "/api/chat.postMessage"; + const res = await post(token, path2, message); if (hasErrors(res)) { throw buildErrorMessage(res); } return res; }; var apiAddReaction = async (token, message) => { - const path = "/api/reactions.add"; - const res = await post(token, path, message); + const path2 = "/api/reactions.add"; + const res = await post(token, path2, message); if (hasErrors(res)) { throw buildErrorMessage(res); } return res; }; var apiUpdateMessage = async (token, message) => { - const path = "/api/chat.update"; - const res = await post(token, path, message); + const path2 = "/api/chat.update"; + const res = await post(token, path2, message); if (hasErrors(res)) { throw buildErrorMessage(res); } return res; }; +var apiUploadFile = async (token, payload) => { + validateUploadFile(payload.filePath); + const fileContent = import_fs2.default.readFileSync(payload.filePath); + const filename = payload.filename || import_path.default.basename(payload.filePath); + const urlRes = await postForm(token, "/api/files.getUploadURLExternal", { + filename, + length: fileContent.length + }); + if (hasErrors(urlRes)) { + throw buildErrorMessage(urlRes); + } + const { upload_url, file_id } = urlRes.response; + const uploadRes = await postBinary(upload_url, fileContent); + if (!uploadRes.ok) { + throw `Error uploading file content (status ${uploadRes.statusCode})`; + } + const completePayload = { + files: [{ id: file_id, title: payload.title || filename }], + channel_id: payload.channel + }; + if (payload.initialComment) { + completePayload.initial_comment = payload.initialComment; + } + const result = await post( + token, + "/api/files.completeUploadExternal", + completePayload + ); + if (hasErrors(result)) { + throw buildErrorMessage(result); + } + return result; +}; // src/util/escaper.js var restoreEscapedNewLine = (text) => text.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n"); @@ -34768,13 +34930,58 @@ var updateMessage = async () => { } }; -// src/invoke.js +// src/upload-file/build-upload-file.js +var buildUploadFile = (channel = "", filePath = "", filename = "", title = "", initialComment = "") => { + if (!channel) { + throw new Error("Channel must be set"); + } + if (!filePath) { + throw new Error("File path must be set"); + } + return { + channel, + filePath, + filename, + title, + initialComment + }; +}; +var build_upload_file_default = buildUploadFile; + +// src/upload-file/index.js var jsonPretty4 = (data) => JSON.stringify(data, void 0, 2); +var uploadFile = async () => { + try { + const token = getRequired("slack-bot-user-oauth-access-token"); + const channel = getRequired("slack-channel"); + const filePath = getRequired("slack-upload-file-path"); + const filename = getOptional("slack-upload-filename"); + const title = getOptional("slack-upload-file-title"); + const initialComment = getOptional("slack-upload-initial-comment"); + const payload = build_upload_file_default( + channel, + filePath, + filename, + title, + initialComment + ); + debugExtra("Upload File PAYLOAD", payload); + const result = await apiUploadFile(token, payload); + debugExtra("Upload File RESULT", result); + const resultAsJson = jsonPretty4(result); + setOutput2("slack-result", resultAsJson); + } catch (error2) { + setFailed2(jsonPretty4(error2)); + } +}; + +// src/invoke.js +var jsonPretty5 = (data) => JSON.stringify(data, void 0, 2); async function validateSubscription() { let repoPrivate; const eventPath = process.env.GITHUB_EVENT_PATH; - if (eventPath && import_fs2.default.existsSync(eventPath)) { - const payload = JSON.parse(import_fs2.default.readFileSync(eventPath, "utf8")); + if (eventPath && import_fs3.default.existsSync(eventPath)) { + const payload = JSON.parse(import_fs3.default.readFileSync(eventPath, "utf8")); repoPrivate = payload?.repository?.private; } const upstream = "archive/github-actions-slack"; @@ -34824,12 +35031,15 @@ var invoke = async () => { case "update-message": await updateMessage(); break; + case "upload-file": + await uploadFile(); + break; default: setFailed2("Unhandled `slack-function`: " + func); break; } } catch (error2) { - setFailed2("invoke failed:" + error2 + ":" + jsonPretty4(error2)); + setFailed2("invoke failed:" + error2 + ":" + jsonPretty5(error2)); } }; var invoke_default = invoke; diff --git a/images/upload.png b/images/upload.png new file mode 100644 index 0000000..2232bbd Binary files /dev/null and b/images/upload.png differ diff --git a/integration-test/end-to-end.js b/integration-test/end-to-end.js index 8ab2471..ea10c33 100644 --- a/integration-test/end-to-end.js +++ b/integration-test/end-to-end.js @@ -4,6 +4,7 @@ import { apiPostMessage, apiAddReaction, apiUpdateMessage, + apiUploadFile, } from "../src/integration/slack-api.js"; import buildMessage from "../src/message/build-message.js"; import buildReaction from "../src/reaction/build-reaction.js"; @@ -103,4 +104,16 @@ const testUpdateMessage = async (channel, token) => { process.env.CHANNEL, process.env.BOT_USER_OAUTH_ACCESS_TOKEN ); + + const uploadResult = await apiUploadFile( + process.env.BOT_USER_OAUTH_ACCESS_TOKEN, + { + channel: process.env.CHANNEL, + filePath: "integration-test/one-does-not-simply.jpg", + filename: "one-does-not-simply.jpg", + title: "Test 5 - Upload File", + initialComment: "Test 5 - testUploadFile 📎", + } + ); + assert.strictEqual(uploadResult.statusCode, 200); })(); diff --git a/integration-test/one-does-not-simply.jpg b/integration-test/one-does-not-simply.jpg new file mode 100644 index 0000000..300e1d2 Binary files /dev/null and b/integration-test/one-does-not-simply.jpg differ diff --git a/src/integration/slack-api-post.js b/src/integration/slack-api-post.js index 82d92a0..7a20878 100644 --- a/src/integration/slack-api-post.js +++ b/src/integration/slack-api-post.js @@ -1,6 +1,8 @@ import https from "https"; import * as context from "../context.js"; +const ALLOWED_UPLOAD_HOSTS = new Set(["files.slack.com"]); + const getOptions = (token, path) => { return { hostname: "slack.com", @@ -64,4 +66,115 @@ const post = (token, path, message) => { }); }; -export { post }; +// Used for Slack methods that require application/x-www-form-urlencoded (e.g. files.getUploadURLExternal) +const postForm = (token, path, fields) => { + return new Promise((resolve, reject) => { + const payload = new URLSearchParams(fields).toString(); + + context.debugExtra("SLACK POST FORM PAYLOAD", payload); + + const options = { + hostname: "slack.com", + port: 443, + path: path, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": Buffer.byteLength(payload), + Authorization: `Bearer ${token}`, + }, + }; + + const req = https.request(options, (res) => { + const chunks = []; + + res.on("data", (chunk) => { + chunks.push(chunk); + }); + + res.on("end", () => { + const result = Buffer.concat(chunks).toString(); + const response = JSON.parse(result); + + let isOk = res.statusCode >= 200 && res.statusCode <= 299; + + if (response && response.hasOwnProperty("ok") && response.ok === false) { + isOk = false; + } + + resolve({ + statusCode: res.statusCode, + statusMessage: res.statusMessage, + ok: isOk, + result: result, + response: response, + }); + }); + }); + + req.on("error", (error) => { + reject(error); + }); + + req.write(payload); + req.end(); + }); +}; + +const validateUploadUrl = (uploadUrl) => { + const url = new URL(uploadUrl); + + if (url.protocol !== "https:") { + throw new Error("Upload URL must use https"); + } + + if (!ALLOWED_UPLOAD_HOSTS.has(url.hostname)) { + throw new Error(`Upload host not allowed: ${url.hostname}`); + } + + return url; +}; + +// Used for step 2 of the files.getUploadURLExternal flow — posts raw binary +// to the pre-signed upload URL returned by Slack (may be a different hostname). +const postBinary = (uploadUrl, fileContent) => { + return new Promise((resolve, reject) => { + const url = validateUploadUrl(uploadUrl); + const options = { + hostname: url.hostname, + port: 443, + path: url.pathname + url.search, + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": fileContent.length, + }, + }; + + context.debug("SLACK UPLOAD BINARY to " + url.hostname + url.pathname); + + const req = https.request(options, (res) => { + const chunks = []; + + res.on("data", (chunk) => { + chunks.push(chunk); + }); + + res.on("end", () => { + resolve({ + statusCode: res.statusCode, + ok: res.statusCode >= 200 && res.statusCode <= 299, + }); + }); + }); + + req.on("error", (error) => { + reject(error); + }); + + req.write(fileContent); + req.end(); + }); +}; + +export { post, postForm, postBinary, validateUploadUrl, ALLOWED_UPLOAD_HOSTS }; diff --git a/src/integration/slack-api.js b/src/integration/slack-api.js index 46872c9..80b7c0b 100644 --- a/src/integration/slack-api.js +++ b/src/integration/slack-api.js @@ -1,4 +1,33 @@ -import { post } from "./slack-api-post.js"; +import fs from "fs"; +import path from "path"; +import { post, postForm, postBinary } from "./slack-api-post.js"; + +// Keep uploads small and predictable to reduce accidental data exfiltration. +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; +const ALLOWED_EXTENSIONS = new Set([ + ".txt", + ".log", + ".json", + ".yaml", + ".yml", + ".xml", + ".pdf", + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".bmp", + ".svg", + ".tif", + ".tiff", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".ppt", + ".pptx", +]); const hasErrors = (res) => !res || !res.ok; @@ -6,6 +35,24 @@ const buildErrorMessage = (res) => { return `Error! ${JSON.stringify(res)} (response)`; }; +const validateUploadFile = (filePath) => { + const extension = path.extname(filePath).toLowerCase(); + + if (!ALLOWED_EXTENSIONS.has(extension)) { + throw new Error(`File type not allowed: ${extension || ""}`); + } + + const stat = fs.statSync(filePath); + + if (!stat.isFile()) { + throw new Error("Upload path must be a file"); + } + + if (stat.size > MAX_UPLOAD_BYTES) { + throw new Error(`File too large: ${stat.size}`); + } +}; + const apiPostMessage = async (token, message) => { const path = "/api/chat.postMessage"; const res = await post(token, path, message); @@ -39,4 +86,55 @@ const apiUpdateMessage = async (token, message) => { return res; }; -export { apiPostMessage, apiAddReaction, apiUpdateMessage }; +const apiUploadFile = async (token, payload) => { + validateUploadFile(payload.filePath); + const fileContent = fs.readFileSync(payload.filePath); + const filename = payload.filename || path.basename(payload.filePath); + + // Step 1: Get upload URL and file ID (requires form encoding, not JSON) + const urlRes = await postForm(token, "/api/files.getUploadURLExternal", { + filename, + length: fileContent.length, + }); + if (hasErrors(urlRes)) { + throw buildErrorMessage(urlRes); + } + + const { upload_url, file_id } = urlRes.response; + + // Step 2: Upload the file binary to the pre-signed URL + const uploadRes = await postBinary(upload_url, fileContent); + if (!uploadRes.ok) { + throw `Error uploading file content (status ${uploadRes.statusCode})`; + } + + // Step 3: Complete the upload and share to channel + const completePayload = { + files: [{ id: file_id, title: payload.title || filename }], + channel_id: payload.channel, + }; + if (payload.initialComment) { + completePayload.initial_comment = payload.initialComment; + } + + const result = await post( + token, + "/api/files.completeUploadExternal", + completePayload + ); + if (hasErrors(result)) { + throw buildErrorMessage(result); + } + + return result; +}; + +export { + apiPostMessage, + apiAddReaction, + apiUpdateMessage, + apiUploadFile, + validateUploadFile, + ALLOWED_EXTENSIONS, + MAX_UPLOAD_BYTES, +}; diff --git a/src/integration/slack-api.test.js b/src/integration/slack-api.test.js index ceb6e4b..7ef8efd 100644 --- a/src/integration/slack-api.test.js +++ b/src/integration/slack-api.test.js @@ -1,6 +1,12 @@ import { jest } from "@jest/globals"; +import fs from "fs"; describe("slack api", () => { + afterEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); + }); + test("fail when slack api wrapper returns ok is false", async () => { expect.assertions(1); @@ -12,6 +18,12 @@ describe("slack api", () => { post: function (token, path, message) { return errorResponse; }, + postForm: function (token, path, fields) { + return errorResponse; + }, + postBinary: function (uploadUrl, fileContent) { + return errorResponse; + }, })); const slackApi = await import("./slack-api.js"); @@ -21,4 +33,42 @@ describe("slack api", () => { expect(error).toContain("Error!"); } }); + + test("reject upload when host is not allowlisted", async () => { + expect.assertions(1); + + jest.unstable_unmockModule("./slack-api-post.js"); + const slackApiPost = await import("./slack-api-post.js"); + + expect(() => + slackApiPost.validateUploadUrl("https://example.com/upload") + ).toThrow("Upload host not allowed: example.com"); + }); + + test("reject upload when extension is not allowlisted", async () => { + expect.assertions(1); + + jest.unstable_unmockModule("./slack-api-post.js"); + const slackApi = await import("./slack-api.js"); + + expect(() => slackApi.validateUploadFile("C:\\temp\\secret.exe")).toThrow( + "File type not allowed: .exe" + ); + }); + + test("reject upload when file exceeds size limit", async () => { + expect.assertions(1); + + jest.spyOn(fs, "statSync").mockReturnValue({ + isFile: () => true, + size: 10 * 1024 * 1024 + 1, + }); + + jest.unstable_unmockModule("./slack-api-post.js"); + const slackApi = await import("./slack-api.js"); + + expect(() => + slackApi.validateUploadFile("C:\\temp\\report.txt") + ).toThrow("File too large"); + }); }); diff --git a/src/invoke.js b/src/invoke.js index 0048c6d..91c0d3c 100644 --- a/src/invoke.js +++ b/src/invoke.js @@ -5,6 +5,7 @@ import * as context from "./context.js"; import { postMessage } from "./message/index.js"; import { addReaction } from "./reaction/index.js"; import { updateMessage } from "./update-message/index.js"; +import { uploadFile } from "./upload-file/index.js"; const jsonPretty = (data) => JSON.stringify(data, undefined, 2); @@ -70,6 +71,9 @@ const invoke = async () => { case "update-message": await updateMessage(); break; + case "upload-file": + await uploadFile(); + break; default: context.setFailed("Unhandled `slack-function`: " + func); break; diff --git a/src/upload-file/build-upload-file.js b/src/upload-file/build-upload-file.js new file mode 100644 index 0000000..60c7e4e --- /dev/null +++ b/src/upload-file/build-upload-file.js @@ -0,0 +1,25 @@ +const buildUploadFile = ( + channel = "", + filePath = "", + filename = "", + title = "", + initialComment = "" +) => { + if (!channel) { + throw new Error("Channel must be set"); + } + + if (!filePath) { + throw new Error("File path must be set"); + } + + return { + channel, + filePath, + filename, + title, + initialComment, + }; +}; + +export default buildUploadFile; diff --git a/src/upload-file/index.js b/src/upload-file/index.js new file mode 100644 index 0000000..d510b51 --- /dev/null +++ b/src/upload-file/index.js @@ -0,0 +1,35 @@ +import * as context from "../context.js"; +import { apiUploadFile } from "../integration/slack-api.js"; +import buildUploadFile from "./build-upload-file.js"; + +const jsonPretty = (data) => JSON.stringify(data, undefined, 2); + +const uploadFile = async () => { + try { + const token = context.getRequired("slack-bot-user-oauth-access-token"); + const channel = context.getRequired("slack-channel"); + const filePath = context.getRequired("slack-upload-file-path"); + const filename = context.getOptional("slack-upload-filename"); + const title = context.getOptional("slack-upload-file-title"); + const initialComment = context.getOptional("slack-upload-initial-comment"); + + const payload = buildUploadFile( + channel, + filePath, + filename, + title, + initialComment + ); + + context.debugExtra("Upload File PAYLOAD", payload); + const result = await apiUploadFile(token, payload); + context.debugExtra("Upload File RESULT", result); + + const resultAsJson = jsonPretty(result); + context.setOutput("slack-result", resultAsJson); + } catch (error) { + context.setFailed(jsonPretty(error)); + } +}; + +export { uploadFile };