Add widget wrapper; Check to ensure content is embeddable

Adds #119
This commit is contained in:
Travis Ralston 2017-10-09 20:26:46 -06:00
parent 403bb8bee6
commit 751e1b9c8c
15 changed files with 232 additions and 17 deletions

View file

@ -23,4 +23,12 @@ demobot:
# Upstream configuration. This should almost never change.
upstreams:
- name: vector
url: "https://scalar.vector.im/api"
url: "https://scalar.vector.im/api"
# IPs and CIDR ranges listed here will be blocked from being widgets.
# Note: Widgets may still be embedded with restricted content, although not through Dimension directly.
widgetBlacklist:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 127.0.0.0/8

12
package-lock.json generated
View file

@ -4441,6 +4441,11 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz",
"integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk="
},
"netmask": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz",
"integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU="
},
"ngx-modialog": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/ngx-modialog/-/ngx-modialog-3.0.3.tgz",
@ -6659,8 +6664,7 @@
"querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
"dev": true
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
},
"querystring-es3": {
"version": "0.2.1",
@ -8425,7 +8429,6 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
"integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=",
"dev": true,
"requires": {
"punycode": "1.3.2",
"querystring": "0.2.0"
@ -8434,8 +8437,7 @@
"punycode": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
"integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=",
"dev": true
"integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0="
}
}
},

View file

@ -31,10 +31,12 @@
"lodash": "^4.17.4",
"matrix-js-sdk": "^0.8.2",
"moment": "^2.18.1",
"netmask": "^1.0.6",
"random-string": "^0.2.0",
"request": "^2.81.0",
"sequelize": "^4.7.5",
"sqlite3": "^3.1.9",
"url": "^0.11.0",
"winston": "^2.3.1"
},
"devDependencies": {

View file

@ -32,7 +32,7 @@ class Dimension {
this._app.use(bodyParser.json());
// Register routes for angular app
this._app.get(['/riot', '/riot/*'], (req, res) => {
this._app.get(['/riot', '/riot/*', '/widget_wrapper', '/widget_wrapper/*'], (req, res) => {
res.sendFile(path.join(__dirname, "..", "web-dist", "index.html"));
});

View file

@ -1,7 +1,12 @@
var IntegrationImpl = require("./integration/impl/index");
var Integrations = require("./integration/index");
var _ = require("lodash");
var log = require("./util/LogService");
const IntegrationImpl = require("./integration/impl/index");
const Integrations = require("./integration/index");
const _ = require("lodash");
const log = require("./util/LogService");
const request = require("request");
const dns = require("dns-then");
const urlParse = require("url");
const Netmask = require("netmask").Netmask;
const config = require("config");
/**
* API handler for the Dimension API
@ -26,6 +31,74 @@ class DimensionApi {
app.delete("/api/v1/dimension/integrations/:roomId/:type/:integrationType", this._removeIntegration.bind(this));
app.put("/api/v1/dimension/integrations/:roomId/:type/:integrationType/state", this._updateIntegrationState.bind(this));
app.get("/api/v1/dimension/integrations/:roomId/:type/:integrationType/state", this._getIntegrationState.bind(this));
app.get("/api/v1/dimension/widgets/embeddable", this._checkEmbeddable.bind(this));
}
_checkEmbeddable(req, res) {
// Unauthed endpoint.
var url = req.query.url;
var parts = urlParse.parse(url);
var processed = false;
// Only allow http and https
if (parts.protocol !== "http:" && parts.protocol !== "https:") {
res.status(400).send({error: "Invalid request scheme " + parts.protocol, canEmbed: false});
processed = true;
return;
}
// Verify the address is permitted for widgets
var hostname = parts.hostname.split(":")[0];
dns.resolve4(hostname).then(addresses => {
log.verbose("DimensionApi", "Hostname " + hostname + " resolves to " + addresses);
if (addresses.length == 0) {
res.status(400).send({error: "Unrecongized address", canEmbed: false});
processed = true;
return;
}
for (var ipOrCidr of config.get("widgetBlacklist")) {
var block = new Netmask(ipOrCidr);
for (var address of addresses) {
if (block.contains(address)) {
res.status(400).send({error: "Address not allowed", canEmbed: false});
processed = true;
return;
}
}
}
}, err => {
log.verbose("DimensionApi", "Error resolving host " + hostname);
log.verbose("DimensionApi", err);
res.status(400).send({error: "DNS error", canEmbed: false});
processed = true;
}).then(() => {
if (processed) return;
// Verify that the content can actually be embedded (CORS)
request(url, (err, response) => {
if (err) {
log.verbose("DimensionApi", "Error contacting host " + hostname);
log.verbose("DimensionApi", err);
res.status(400).send({error: "Host error", canEmbed: false});
return;
}
if (response.statusCode >= 200 && response.statusCode < 300) {
// 200 OK
var headers = response.headers;
var xFrameOptions = (headers['x-frame-options'] || '').toLowerCase();
if (xFrameOptions === 'sameorigin' || xFrameOptions === 'deny') {
res.status(400).send({error: "X-Frame-Options forbids embedding", canEmbed: false});
} else res.status(200).send({canEmbed: true});
} else {
res.status(400).send({error: "Unsuccessful status code: " + response.statusCode, canEmbed: false});
}
});
});
}
_getIntegration(integrationConfig, roomId, scalarToken) {
@ -77,7 +150,7 @@ class DimensionApi {
for (var toRemove of remove) {
var idx = integrations.indexOf(toRemove);
if (idx === -1) continue;
log.warn("DimensionApi", "Disabling integration " + toRemove.name +" due to an error encountered in setup");
log.warn("DimensionApi", "Disabling integration " + toRemove.name + " due to an error encountered in setup");
integrations.splice(idx, 1);
}

View file

@ -24,6 +24,7 @@ import { IrcApiService } from "./shared/irc-api.service";
import { TravisCiConfigComponent } from "./configs/travisci/travisci-config.component";
import { CustomWidgetConfigComponent } from "./configs/widget/custom_widget/custom_widget-config.component";
import { MyFilterPipe } from "./shared/my-filter.pipe";
import { WidgetWrapperComponent } from "./widget_wrapper/widget_wrapper.component";
@NgModule({
imports: [
@ -49,6 +50,7 @@ import { MyFilterPipe } from "./shared/my-filter.pipe";
TravisCiConfigComponent,
CustomWidgetConfigComponent,
MyFilterPipe,
WidgetWrapperComponent,
// Vendor
],
@ -57,6 +59,7 @@ import { MyFilterPipe } from "./shared/my-filter.pipe";
ScalarService,
IntegrationService,
IrcApiService,
{provide: Window, useValue: window},
// Vendor
],

View file

@ -1,10 +1,12 @@
import { RouterModule, Routes } from "@angular/router";
import { HomeComponent } from "./home/home.component";
import { RiotComponent } from "./riot/riot.component";
import { WidgetWrapperComponent } from "./widget_wrapper/widget_wrapper.component";
const routes: Routes = [
{path: "", component: HomeComponent},
{path: "riot", component: RiotComponent},
{path: "riot/widget_wrapper", component: WidgetWrapperComponent},
];
export const routing = RouterModule.forRoot(routes);

View file

@ -27,7 +27,7 @@
</span>
</div>
</div>
<div class="col-md-12 removable" *ngFor="let widget of widgets trackById">
<div class="col-md-12 removable widget-item" *ngFor="let widget of widgets trackById">
{{ widget.name || widget.url }} <span class="text-muted" *ngIf="widget.ownerId">(added by {{ widget.ownerId }})</span>
<button type="button" class="btn btn-outline-info btn-sm" (click)="editWidget(widget)"
style="margin-top: -5px;" [disabled]="isUpdating">

View file

@ -1 +1,4 @@
// component styles are encapsulated and only applied to their components
.widget-item {
margin-top: 3px;
}

View file

@ -1,6 +1,6 @@
import { Component } from "@angular/core";
import { ModalComponent, DialogRef } from "ngx-modialog";
import { WidgetComponent } from "../widget.component";
import { WidgetComponent, SCALAR_WIDGET_LINKS } from "../widget.component";
import { ScalarService } from "../../../shared/scalar.service";
import { ConfigModalContext } from "../../../integration/integration.component";
import { ToasterService } from "angular2-toaster";
@ -21,23 +21,46 @@ export class CustomWidgetConfigComponent extends WidgetComponent implements Moda
public widgetUrl = "";
private toggledWidgets: string[] = [];
private wrapperUrl = "";
constructor(public dialog: DialogRef<ConfigModalContext>,
private toaster: ToasterService,
scalarService: ScalarService) {
scalarService: ScalarService,
window: Window) {
super(scalarService, dialog.context.roomId);
this.getWidgetsOfType(WIDGET_DIM_CUSTOM, WIDGET_SCALAR_CUSTOM).then(widgets => {
this.widgets = widgets;
this.isLoading = false;
this.isUpdating = false;
// Unwrap URLs for easy-editing
for (let widget of this.widgets) {
widget.url = this.getWrappedUrl(widget.url);
}
});
this.wrapperUrl = window.location.origin + "/riot/widget_wrapper?url=";
}
private getWrappedUrl(url: string): string {
const urls = [this.wrapperUrl].concat(SCALAR_WIDGET_LINKS);
for (var scalarUrl of urls) {
if (url.startsWith(scalarUrl)) {
return decodeURIComponent(url.substring(scalarUrl.length));
}
}
return url;
}
private wrapUrl(url: string): string {
return this.wrapperUrl + encodeURIComponent(url);
}
public addWidget() {
let constructedWidget: Widget = {
id: "dimension-" + (new Date().getTime()),
url: this.widgetUrl,
url: this.wrapUrl(this.widgetUrl),
type: WIDGET_DIM_CUSTOM,
name: "Custom Widget",
};
@ -45,6 +68,7 @@ export class CustomWidgetConfigComponent extends WidgetComponent implements Moda
this.isUpdating = true;
this.scalarApi.setWidget(this.roomId, constructedWidget)
.then(() => this.widgets.push(constructedWidget))
.then(() => constructedWidget.url = this.getWrappedUrl(constructedWidget.url)) // unwrap for immediate editing
.then(() => {
this.isUpdating = false;
this.widgetUrl = "";
@ -64,7 +88,7 @@ export class CustomWidgetConfigComponent extends WidgetComponent implements Moda
}
widget.name = widget.newName || "Custom Widget";
widget.url = widget.newUrl;
widget.url = this.wrapUrl(widget.newUrl);
this.isUpdating = true;
this.scalarApi.setWidget(this.roomId, widget)

View file

@ -1,6 +1,13 @@
import { ScalarService } from "../../shared/scalar.service";
import { Widget, ScalarToWidgets } from "../../shared/models/widget";
export const SCALAR_WIDGET_LINKS = [
'https://scalar-staging.riot.im/scalar/api/widgets/generic.html?url=',
'https://scalar-staging.vector.im/scalar/api/widgets/generic.html?url=',
'https://scalar-develop.riot.im/scalar/api/widgets/generic.html?url=',
'https://demo.riot.im/scalar/api/widgets/generic.html?url=',
];
export class WidgetComponent {
constructor(protected scalarApi: ScalarService, protected roomId: string) {

View file

@ -34,4 +34,10 @@ export class ApiService {
return this.http.get(url, {params: {scalar_token: scalarToken}})
.map(res => res.json()).toPromise();
}
isEmbeddable(checkUrl: string): Promise<any> {
const url = "/api/v1/dimension/widgets/embeddable";
return this.http.get(url, {params: {url: checkUrl}})
.map(res => res.json()).toPromise();
}
}

View file

@ -0,0 +1,13 @@
<div class="wrapper">
<div class="control-page" *ngIf="isLoading || !canEmbed">
<div class="loading-badge" *ngIf="isLoading">
<i class="fa fa-circle-o-notch fa-spin"></i>
Loading...
</div>
<div class="embed-failed" *ngIf="!isLoading && !canEmbed">
<p class="ban"><i class="fa fa-ban"></i></p>
<h4>Sorry, this content cannot be embedded</h4>
</div>
</div>
<iframe [src]="embedUrl" *ngIf="!isLoading && canEmbed" frameborder="0" allowfullscreen></iframe>
</div>

View file

@ -0,0 +1,41 @@
// component styles are encapsulated and only applied to their components
.control-page {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: 0;
padding: 0;
background-color: #222;
color: #eee;
}
.loading-badge {
text-align: center;
font-size: 20px;
position: relative;
top: calc(50% - 10px);
}
.embed-failed {
text-align: center;
position: relative;
height: 300px;
top: calc(50% - 150px);
}
.embed-failed .ban {
font-size: 145px;
color: #bd362f;
}
iframe {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
}

View file

@ -0,0 +1,31 @@
import { Component } from "@angular/core";
import { ApiService } from "../shared/api.service";
import { ActivatedRoute } from "@angular/router";
import { DomSanitizer, SafeUrl } from "@angular/platform-browser";
@Component({
selector: "my-widget-wrapper",
templateUrl: "widget_wrapper.component.html",
styleUrls: ["widget_wrapper.component.scss"],
})
export class WidgetWrapperComponent {
public isLoading = true;
public canEmbed = false;
public embedUrl: SafeUrl = null;
constructor(api: ApiService, activatedRoute: ActivatedRoute, sanitizer: DomSanitizer) {
let params: any = activatedRoute.snapshot.queryParams;
api.isEmbeddable(params.url).then(result => {
this.canEmbed = result.canEmbed;
this.isLoading = false;
this.embedUrl = sanitizer.bypassSecurityTrustResourceUrl(params.url);
}).catch(err => {
console.error(err);
this.canEmbed = false;
this.isLoading = false;
});
}
}