diff --git a/src/api/dimension/DimensionWidgetService.ts b/src/api/dimension/DimensionWidgetService.ts new file mode 100644 index 0000000..2876dd2 --- /dev/null +++ b/src/api/dimension/DimensionWidgetService.ts @@ -0,0 +1,75 @@ +import { GET, Path, QueryParam } from "typescript-rest"; +import { LogService } from "matrix-js-snippets"; +import * as url from "url"; +import { ApiError } from "../ApiError"; +import * as dns from "dns-then"; +import config from "../../config"; +import { Netmask } from "netmask"; +import * as request from "request"; + +interface EmbedCapabilityResponse { + canEmbed: boolean; +} + +/** + * API for widgets + */ +@Path("/api/v1/dimension/widgets") +export class DimensionWidgetService { + + @GET + @Path("embeddable") + public async isEmbeddable(@QueryParam("url") checkUrl: string): Promise { + LogService.info("DimensionWidgetService", "Checking to see if a url is embeddable: " + checkUrl); + + const parsed = url.parse(checkUrl); + + // Only allow http and https + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new ApiError(400, "Invalid scheme: " + parsed.protocol); + } + + // Get the IP address we're trying to connect to so we can ensure it's not blacklisted + const hostname = parsed.hostname.split(":")[0]; + let addresses = []; + try { + addresses = await dns.resolve(hostname); + } catch (err) { + LogService.error("DimensionWidgetService", err); + } + if (!addresses || addresses.length === 0) throw new ApiError(400, "Cannot resolve host " + hostname); + + // Check the blacklist + for (const ipOrCidr of config.widgetBlacklist) { + const block = new Netmask(ipOrCidr); + for (const address of addresses) { + if (block.contains(address)) { + throw new ApiError(400, "Address blacklisted"); + } + } + } + + // Now we need to verify we can actually make the request + await new Promise((resolve, _reject) => { + request(checkUrl, (err, response) => { + if (err) { + LogService.error("DimensionWidgetService", err); + throw new ApiError(400, "Failed to contact host"); + } + + if (response.statusCode >= 200 && response.statusCode < 300) { + // 200 OK + const xFrameOptions = (response.headers["x-frame-options"] || '').toLowerCase(); + + if (xFrameOptions === "sameorigin" || xFrameOptions === "deny") { + throw new ApiError(400, "X-Frame-Options prevents embedding"); + } + + resolve(); + } else throw new ApiError(400, "Non-success status code returned"); + }); + }); + + return {canEmbed: true}; + } +} \ No newline at end of file diff --git a/web/app/shared/services/integrations/integrations-api.service.ts b/web/app/shared/services/integrations/integrations-api.service.ts index ecde3f4..4030802 100644 --- a/web/app/shared/services/integrations/integrations-api.service.ts +++ b/web/app/shared/services/integrations/integrations-api.service.ts @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { Http } from "@angular/http"; import { AuthedApi } from "../authed-api"; import { FE_IntegrationsResponse } from "../../models/dimension-responses"; -import { FE_Integration, FE_Widget } from "../../models/integration"; +import { FE_Integration } from "../../models/integration"; @Injectable() export class IntegrationsApiService extends AuthedApi { @@ -26,15 +26,6 @@ export class IntegrationsApiService extends AuthedApi { return this.authedPost("/api/v1/dimension/integrations/room/" + roomId + "/integrations/" + category + "/" + type + "/config", newConfig).map(r => r.json()).toPromise(); } - public getWidget(type: string): Promise { - return this.getIntegration("widget", type).then(i => i); - } - - public isEmbeddable(url: string): Promise { // 200 = success, anything else = error - return this.http.get("/api/v1/dimension/widgets/embeddable", {params: {url: url}}) - .map(r => r.json()).toPromise(); - } - public removeIntegration(category: string, type: string, roomId: string): Promise { return this.authedDelete("/api/v1/dimension/integrations/room/" + roomId + "/integrations/" + category + "/" + type).map(r => r.json()).toPromise(); }