mirror of
https://github.com/wassname/talk.git
synced 2026-07-08 13:20:50 +08:00
Merge branch 'next' into next-ui-select
This commit is contained in:
@@ -29,9 +29,14 @@ jobs:
|
||||
- run:
|
||||
name: Update NPM
|
||||
command: sudo npm update -g npm
|
||||
- run:
|
||||
name: Audit dependencies
|
||||
command: npm audit
|
||||
# Disabled until there's capabilityies to ignore a specific vun. Related:
|
||||
# https://npm.community/t/please-provide-option-to-ignore-packages-in-npm-audit/403/4
|
||||
# https://github.com/npm/cli/pull/10
|
||||
# https://github.com/npm/rfcs/pull/18
|
||||
#
|
||||
# - run:
|
||||
# name: Audit dependencies
|
||||
# command: npm audit
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: npm ci
|
||||
|
||||
Generated
+5
@@ -6725,6 +6725,11 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
|
||||
"integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ="
|
||||
},
|
||||
"content-security-policy-builder": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-security-policy-builder/-/content-security-policy-builder-2.0.0.tgz",
|
||||
"integrity": "sha512-j+Nhmj1yfZAikJLImCvPJFE29x/UuBi+/MWqggGGc515JKaZrjuei2RhULJmy0MsstW3E3htl002bwmBNMKr7w=="
|
||||
},
|
||||
"content-type": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"bunyan-prettystream": "^0.1.3",
|
||||
"cheerio": "^1.0.0-rc.2",
|
||||
"consolidate": "0.14.0",
|
||||
"content-security-policy-builder": "^2.0.0",
|
||||
"convict": "^4.3.1",
|
||||
"cors": "^2.8.4",
|
||||
"dataloader": "^1.4.0",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { generateFrameOptions } from "talk-server/app/middleware/csp/tenant";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
it("denies when the tenant has no specified domains", () => {
|
||||
const tenant = { domains: [] };
|
||||
const req = {} as Request;
|
||||
|
||||
expect(generateFrameOptions(req, tenant)).toEqual("deny");
|
||||
});
|
||||
|
||||
it("allow-from single domain when there is one domain", () => {
|
||||
const tenant = { domains: ["https://coralproject.net"] };
|
||||
const req = {} as Request;
|
||||
|
||||
expect(generateFrameOptions(req, tenant)).toEqual(
|
||||
"allow-from https://coralproject.net"
|
||||
);
|
||||
});
|
||||
|
||||
it("deny from the domain when it does not provide and there are multiple tenants domains", () => {
|
||||
const tenant = {
|
||||
domains: ["https://coralproject.net", "https://news.coralproject.net"],
|
||||
};
|
||||
const req = { headers: {}, query: { parentUrl: "" } } as Request;
|
||||
|
||||
expect(generateFrameOptions(req, tenant)).toEqual("deny");
|
||||
});
|
||||
|
||||
it("allows from the domain when it does not provide a match and there are multiple tenants domains", () => {
|
||||
const tenant = {
|
||||
domains: ["https://coralproject.net", "https://news.coralproject.net"],
|
||||
};
|
||||
const req = {
|
||||
headers: {},
|
||||
query: { parentUrl: "https://blog.coralproject.net/a/page" },
|
||||
} as Request;
|
||||
|
||||
expect(generateFrameOptions(req, tenant)).toEqual("deny");
|
||||
});
|
||||
|
||||
it("allows from the domain when it does provide a match and there are multiple tenants domains", () => {
|
||||
const tenant = {
|
||||
domains: ["https://coralproject.net", "https://news.coralproject.net"],
|
||||
};
|
||||
const req = {
|
||||
headers: {},
|
||||
query: { parentUrl: "https://news.coralproject.net/a/page" },
|
||||
} as Request;
|
||||
|
||||
expect(generateFrameOptions(req, tenant)).toEqual(
|
||||
"allow-from https://news.coralproject.net"
|
||||
);
|
||||
});
|
||||
|
||||
it("it prefixes domains of the tenant when generating the frame option", () => {
|
||||
const tenant = {
|
||||
domains: ["coralproject.net", "news.coralproject.net"],
|
||||
};
|
||||
const req = {
|
||||
headers: {},
|
||||
query: { parentUrl: "http://news.coralproject.net/a/page" },
|
||||
} as Request;
|
||||
|
||||
expect(generateFrameOptions(req, tenant)).toEqual(
|
||||
"allow-from http://news.coralproject.net"
|
||||
);
|
||||
});
|
||||
|
||||
it("it prefixes domains of the tenant when generating the frame option", () => {
|
||||
const tenant = {
|
||||
domains: ["coralproject.net", "news.coralproject.net"],
|
||||
};
|
||||
const req = {
|
||||
headers: {},
|
||||
query: { parentUrl: "https://news.coralproject.net/a/page" },
|
||||
} as Request;
|
||||
|
||||
expect(generateFrameOptions(req, tenant)).toEqual(
|
||||
"allow-from https://news.coralproject.net"
|
||||
);
|
||||
});
|
||||
|
||||
it("it prefixes domains of the tenant when generating the frame option and denies based on it", () => {
|
||||
const tenant = {
|
||||
domains: ["coralproject.net", "news.coralproject.net"],
|
||||
};
|
||||
const req = {
|
||||
headers: {},
|
||||
query: { parentUrl: "http://news.coralproject.net/a/page" },
|
||||
} as Request;
|
||||
|
||||
expect(generateFrameOptions(req, tenant)).toEqual(
|
||||
"allow-from http://news.coralproject.net"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import builder from "content-security-policy-builder";
|
||||
import {
|
||||
doesRequireSchemePrefixing,
|
||||
extractParentsOrigin,
|
||||
getOrigin,
|
||||
isURLSecure,
|
||||
prefixSchemeIfRequired,
|
||||
} from "talk-server/app/url";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { Request, RequestHandler } from "talk-server/types/express";
|
||||
|
||||
/**
|
||||
* cspMiddleware handles adding the CSP middleware to each outgoing request.
|
||||
*/
|
||||
export const cspTenantMiddleware: RequestHandler = (req, res, next) => {
|
||||
const tenant = req.talk!.tenant;
|
||||
if (!tenant) {
|
||||
// There is no tenant for the request, don't add any headers.
|
||||
return next();
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
generateContentSecurityPolicy(req, tenant)
|
||||
);
|
||||
|
||||
// Add some fallbacks for IE.
|
||||
res.setHeader("X-Frame-Options", generateFrameOptions(req, tenant));
|
||||
res.setHeader("X-XSS-Protection", "1; mode=block");
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
function generateContentSecurityPolicy(
|
||||
req: Request,
|
||||
tenant: Pick<Tenant, "domains">
|
||||
) {
|
||||
const directives: Record<string, any> = {};
|
||||
|
||||
// Only the domains that are allowed by the tenant may embed Talk.
|
||||
directives.frameAncestors =
|
||||
tenant.domains.length > 0 ? tenant.domains : ["'none'"];
|
||||
|
||||
// Build the directive.
|
||||
const directive = builder({ directives });
|
||||
|
||||
return directive;
|
||||
}
|
||||
|
||||
export function generateFrameOptions(
|
||||
req: Request,
|
||||
tenant: Pick<Tenant, "domains">
|
||||
) {
|
||||
// If there aren't any domains, then we reject it.
|
||||
if (tenant.domains.length === 0) {
|
||||
return "deny";
|
||||
}
|
||||
|
||||
// If there is only one domain on the tenant, and we don't require
|
||||
// prefixing, then return it!
|
||||
if (
|
||||
tenant.domains.length === 1 &&
|
||||
!doesRequireSchemePrefixing(tenant.domains[0])
|
||||
) {
|
||||
return `allow-from ${getOrigin(tenant.domains[0])}`;
|
||||
}
|
||||
|
||||
// Grab the parent's hostname.
|
||||
const parentsOrigin = extractParentsOrigin(req);
|
||||
if (!parentsOrigin) {
|
||||
return "deny";
|
||||
}
|
||||
|
||||
// Grab the status if the parent url is secure.
|
||||
const parentSecure = isURLSecure(parentsOrigin);
|
||||
if (parentSecure === null) {
|
||||
return "deny";
|
||||
}
|
||||
|
||||
// If there is only one domain on the tenant, and we require prefixing, then
|
||||
// return it with prefixing!
|
||||
if (
|
||||
tenant.domains.length === 1 &&
|
||||
!doesRequireSchemePrefixing(tenant.domains[0])
|
||||
) {
|
||||
return `allow-from ${getOrigin(
|
||||
prefixSchemeIfRequired(parentSecure, tenant.domains[0])
|
||||
)}`;
|
||||
}
|
||||
|
||||
// As we can only return a single domain in the `allow-from` directive as per
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
|
||||
// We need to find the domain that is asking so we can respond with the right
|
||||
// result, sort of like CORS!
|
||||
const allowFrom = tenant.domains
|
||||
.map(domain => getOrigin(prefixSchemeIfRequired(parentSecure, domain)))
|
||||
.find(origin => origin === parentsOrigin);
|
||||
if (!allowFrom) {
|
||||
return "deny";
|
||||
}
|
||||
|
||||
return `allow-from ${allowFrom}`;
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import { RequestHandler } from "express";
|
||||
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { Request } from "talk-server/types/express";
|
||||
import { RequestHandler } from "talk-server/types/express";
|
||||
|
||||
export interface MiddlewareOptions {
|
||||
cache: TenantCache;
|
||||
}
|
||||
|
||||
export default ({ cache }: MiddlewareOptions): RequestHandler => async (
|
||||
req: Request,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
export const tenantMiddleware = ({
|
||||
cache,
|
||||
}: MiddlewareOptions): RequestHandler => async (req, res, next) => {
|
||||
try {
|
||||
// Attach the tenant to the request.
|
||||
const tenant = await cache.retrieveByDomain(req.hostname);
|
||||
|
||||
@@ -2,7 +2,7 @@ import express from "express";
|
||||
|
||||
import { AppOptions } from "talk-server/app";
|
||||
import { tenantInstallHandler } from "talk-server/app/handlers/api/tenant/install";
|
||||
import tenantMiddleware from "talk-server/app/middleware/tenant";
|
||||
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
|
||||
import { RouterOptions } from "talk-server/app/router/types";
|
||||
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ export function createClientTargetRouter({
|
||||
// Create a router.
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", cacheHeadersMiddleware(cacheDuration), (req, res) =>
|
||||
res.render(view, { staticURI })
|
||||
);
|
||||
router.use(cacheHeadersMiddleware(cacheDuration));
|
||||
|
||||
router.get("/", (req, res) => res.render(view, { staticURI }));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import playground from "talk-server/app/middleware/playground";
|
||||
import { RouterOptions } from "talk-server/app/router/types";
|
||||
import logger from "talk-server/logger";
|
||||
|
||||
import { cspTenantMiddleware } from "talk-server/app/middleware/csp/tenant";
|
||||
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
|
||||
import { createAPIRouter } from "./api";
|
||||
import { createClientTargetRouter } from "./client";
|
||||
|
||||
@@ -21,16 +23,26 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
|
||||
attachGraphiQL(router, app);
|
||||
}
|
||||
|
||||
router.use(tenantMiddleware({ cache: app.tenantCache }));
|
||||
router.use(cspTenantMiddleware);
|
||||
|
||||
const staticURI = app.config.get("static_uri");
|
||||
|
||||
// Add the embed targets.
|
||||
router.use(
|
||||
"/embed/stream",
|
||||
createClientTargetRouter({ staticURI, view: "stream" })
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
view: "stream",
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
"/embed/auth",
|
||||
createClientTargetRouter({ staticURI, view: "auth", cacheDuration: false })
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
view: "auth",
|
||||
cacheDuration: false,
|
||||
})
|
||||
);
|
||||
|
||||
// Add the standalone targets.
|
||||
@@ -40,15 +52,19 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
|
||||
installedMiddleware({
|
||||
tenantCache: app.tenantCache,
|
||||
}),
|
||||
createClientTargetRouter({ staticURI, view: "admin", cacheDuration: false })
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
view: "admin",
|
||||
cacheDuration: false,
|
||||
})
|
||||
);
|
||||
router.use(
|
||||
"/install",
|
||||
// If we're already installed, redirect the user to the admin page.
|
||||
installedMiddleware({
|
||||
tenantCache: app.tenantCache,
|
||||
redirectIfInstalled: true,
|
||||
redirectURL: "/admin",
|
||||
tenantCache: app.tenantCache,
|
||||
}),
|
||||
createClientTargetRouter({
|
||||
staticURI,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Request } from "express";
|
||||
import {
|
||||
doesRequireSchemePrefixing,
|
||||
extractParentsOrigin,
|
||||
prefixSchemeIfRequired,
|
||||
} from "talk-server/app/url";
|
||||
|
||||
it("extracts the url when the parentUrl is not provided", () => {
|
||||
const req = { headers: {}, query: {} } as Request;
|
||||
|
||||
expect(extractParentsOrigin(req)).toEqual(null);
|
||||
});
|
||||
|
||||
it("extracts the url when the parentUrl is provided but is empty", () => {
|
||||
const req = { headers: {}, query: { parentUrl: "" } } as Request;
|
||||
|
||||
expect(extractParentsOrigin(req)).toEqual(null);
|
||||
});
|
||||
|
||||
it("extracts the url when the parentUrl is provided", () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
query: { parentUrl: "https://coralproject.net/" },
|
||||
} as Request;
|
||||
|
||||
expect(extractParentsOrigin(req)).toEqual("https://coralproject.net");
|
||||
});
|
||||
|
||||
it("extracts the url when the referer header is provided but is empty", () => {
|
||||
const req = { headers: {}, query: {} } as Request;
|
||||
|
||||
req.headers.referer = "";
|
||||
|
||||
expect(extractParentsOrigin(req)).toEqual(null);
|
||||
});
|
||||
|
||||
it("extracts the url when the referer header is provided", () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
query: {},
|
||||
} as Request;
|
||||
|
||||
req.headers.referer = "https://coralproject.net/";
|
||||
|
||||
expect(extractParentsOrigin(req)).toEqual("https://coralproject.net");
|
||||
});
|
||||
|
||||
it("does not do any prefixing", () => {
|
||||
expect(prefixSchemeIfRequired(true, "https://coralproject.net")).toEqual(
|
||||
"https://coralproject.net"
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes the url with https://", () => {
|
||||
expect(prefixSchemeIfRequired(true, "coralproject.net")).toEqual(
|
||||
"https://coralproject.net"
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes the url with http://", () => {
|
||||
expect(prefixSchemeIfRequired(false, "coralproject.net")).toEqual(
|
||||
"http://coralproject.net"
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes the url with http://", () => {
|
||||
expect(prefixSchemeIfRequired(false, "//coralproject.net")).toEqual(
|
||||
"http://coralproject.net"
|
||||
);
|
||||
});
|
||||
|
||||
it("determines prefixing requirements correctly", () => {
|
||||
expect(doesRequireSchemePrefixing("")).toEqual(true);
|
||||
expect(doesRequireSchemePrefixing("coralproject.net")).toEqual(true);
|
||||
expect(doesRequireSchemePrefixing("localhost:8080")).toEqual(true);
|
||||
expect(doesRequireSchemePrefixing("http://coralproject.net")).toEqual(false);
|
||||
expect(doesRequireSchemePrefixing("https://coralproject.net")).toEqual(false);
|
||||
expect(doesRequireSchemePrefixing("http://localhost:8080")).toEqual(false);
|
||||
expect(doesRequireSchemePrefixing("https://localhost:8080")).toEqual(false);
|
||||
});
|
||||
@@ -10,3 +10,75 @@ export function reconstructURL(req: Request, path: string = "/"): string {
|
||||
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function doesRequireSchemePrefixing(url: string) {
|
||||
return !url.startsWith("http");
|
||||
}
|
||||
|
||||
export function isURLSecure(url: string) {
|
||||
if (doesRequireSchemePrefixing(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url.startsWith("https://");
|
||||
}
|
||||
|
||||
export function getHostname(url: string) {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrigin(url: string) {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function prefixSchemeIfRequired(secure: boolean, url: string) {
|
||||
if (doesRequireSchemePrefixing(url)) {
|
||||
return (
|
||||
"http" +
|
||||
(secure ? "s" : "") +
|
||||
(url.indexOf("//") === -1 ? "://" : ":") +
|
||||
url
|
||||
);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* extractParentsOrigin will pull the parent's origin out.
|
||||
*
|
||||
* @param req the request where we want to extract the parent's hostname from.
|
||||
*/
|
||||
export function extractParentsOrigin(req: Request) {
|
||||
// The only two places this could be is in the referer header or the parentUrl
|
||||
// query parameter (injected by pym.js). If both of these are empty, then we
|
||||
// can't find anything.
|
||||
if (!req.headers.referer && !req.query.parentUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the referer header is defined, then use it.
|
||||
if (req.headers.referer && req.headers.referer.length > 0) {
|
||||
// If the header contains multiple values, return the first one.
|
||||
if (Array.isArray(req.headers.referer)) {
|
||||
return getOrigin(req.headers.referer[0]);
|
||||
}
|
||||
|
||||
return getOrigin(req.headers.referer);
|
||||
}
|
||||
|
||||
// If the parentUrl query parameter is provided, then try to parse it.
|
||||
if (req.query.parentUrl) {
|
||||
return getOrigin(req.query.parentUrl);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request } from "express";
|
||||
import { NextFunction, Request as ExpressRequest, Response } from "express";
|
||||
|
||||
import TenantContext from "talk-server/graph/tenant/context";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
@@ -15,7 +15,13 @@ export interface TalkRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Request extends Request {
|
||||
export interface Request extends ExpressRequest {
|
||||
talk?: TalkRequest;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export type RequestHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => void;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
declare module "content-security-policy-builder" {
|
||||
export default function builder({
|
||||
directives,
|
||||
}: {
|
||||
directives: Record<string, any>;
|
||||
}): string;
|
||||
}
|
||||
Vendored
+2
-2
@@ -54,8 +54,8 @@ declare module "fluent/compat" {
|
||||
protected value: any;
|
||||
protected opts: any;
|
||||
constructor(value: any, opts?: any);
|
||||
valueOf(): any;
|
||||
toString(bundle: FluentBundle): string;
|
||||
public valueOf(): any;
|
||||
public toString(bundle: FluentBundle): string;
|
||||
}
|
||||
|
||||
export class FluentNumber extends FluentType {}
|
||||
|
||||
Reference in New Issue
Block a user