diff --git a/packages/binding-http/README.md b/packages/binding-http/README.md index b87a274d8..86462bfea 100644 --- a/packages/binding-http/README.md +++ b/packages/binding-http/README.md @@ -180,6 +180,7 @@ The protocol binding can be configured using his constructor or trough servient baseUri?: string // A Base URI to be used in the TD in cases where the client will access a different URL than the actual machine serving the thing. [See Using BaseUri below] urlRewrite?: Record // A record to allow for other URLs pointing to existing endpoints, e.g., { "/myroot/myUrl": "/test/properties/test" } middleware?: MiddlewareRequestHandler; // the MiddlewareRequestHandler function. See [Adding a middleware] section below. + allowedOrigins?: string; // Configures the Access-Control-Allow-Origin header. Defaults to "*" (any origin). See [Configuring CORS] section below. } ``` @@ -305,6 +306,21 @@ The exposed thing on the internal server will product form URLs such as: > `address` tells the HttpServer a specific local network interface to bind its TCP listener. +### Configuring CORS + +By default, the HTTP binding sets the `Access-Control-Allow-Origin` header to `"*"`, allowing any origin to access exposed Things. You can restrict this to a specific origin using the `allowedOrigins` configuration option: + +```js +servient.addServer( + new HttpServer({ + port: 8080, + allowedOrigins: "https://my-app.example.com", + }) +); +``` + +When a security scheme (e.g. `basic`, `bearer`) is configured, the server echoes the request's `Origin` header and sets `Access-Control-Allow-Credentials: true`, regardless of the `allowedOrigins` value. This is required for browsers to send credentials in cross-origin requests. + ### Adding a middleware HttpServer supports the addition of **middleware** to handle the raw HTTP requests before they hit the Servient. In the middleware function, you can run some logic to filter and eventually reject HTTP requests (e.g. based on some custom headers). diff --git a/packages/binding-http/src/http-server.ts b/packages/binding-http/src/http-server.ts index 4f4f7dd62..9ffff3603 100644 --- a/packages/binding-http/src/http-server.ts +++ b/packages/binding-http/src/http-server.ts @@ -65,6 +65,7 @@ export default class HttpServer implements ProtocolServer { private readonly baseUri?: string; private readonly urlRewrite?: Record; private readonly devFriendlyUri: boolean; + private readonly allowedOrigins: string; private readonly supportedSecuritySchemes: string[] = ["nosec"]; private readonly validOAuthClients: RegExp = /.*/g; private readonly server: http.Server | https.Server; @@ -85,6 +86,7 @@ export default class HttpServer implements ProtocolServer { this.urlRewrite = config.urlRewrite; this.middleware = config.middleware; this.devFriendlyUri = config.devFriendlyUri ?? true; + this.allowedOrigins = config.allowedOrigins ?? "*"; const router = Router({ ignoreTrailingSlash: true, @@ -251,6 +253,10 @@ export default class HttpServer implements ProtocolServer { return this.things; } + public getAllowedOrigins(): string { + return this.allowedOrigins; + } + /** returns server port number and indicates that server is running when larger than -1 */ public getPort(): number { const address = this.server?.address(); diff --git a/packages/binding-http/src/http.ts b/packages/binding-http/src/http.ts index 5d4b3e839..77aa1d1f9 100644 --- a/packages/binding-http/src/http.ts +++ b/packages/binding-http/src/http.ts @@ -47,6 +47,11 @@ export interface HttpConfig { security?: SecurityScheme[]; devFriendlyUri?: boolean; middleware?: MiddlewareRequestHandler; + /** + * Configures the Access-Control-Allow-Origin header. + * Default is "*" (any origin allowed). + */ + allowedOrigins?: string; } export interface OAuth2ServerConfig extends SecurityScheme { diff --git a/packages/binding-http/src/routes/action.ts b/packages/binding-http/src/routes/action.ts index 4acbf133a..9fa5c7e6e 100644 --- a/packages/binding-http/src/routes/action.ts +++ b/packages/binding-http/src/routes/action.ts @@ -67,7 +67,7 @@ export default async function actionRoute( return; } // TODO: refactor this part to move into a common place - setCorsForThing(req, res, thing); + setCorsForThing(req, res, thing, this.getAllowedOrigins()); let corsPreflightWithCredentials = false; const securityScheme = thing.securityDefinitions[Helpers.toStringArray(thing.security)[0]].scheme; @@ -110,6 +110,6 @@ export default async function actionRoute( } else { // may have been OPTIONS that failed the credentials check // as a result, we pass corsPreflightWithCredentials - respondUnallowedMethod(req, res, "POST", corsPreflightWithCredentials); + respondUnallowedMethod(req, res, "POST", corsPreflightWithCredentials, this.getAllowedOrigins()); } } diff --git a/packages/binding-http/src/routes/common.ts b/packages/binding-http/src/routes/common.ts index b4d164fad..7751a3b71 100644 --- a/packages/binding-http/src/routes/common.ts +++ b/packages/binding-http/src/routes/common.ts @@ -21,7 +21,8 @@ export function respondUnallowedMethod( req: IncomingMessage, res: ServerResponse, allowed: string, - corsPreflightWithCredentials = false + corsPreflightWithCredentials = false, + allowedOrigins = "*" ): void { // Always allow OPTIONS to handle CORS pre-flight requests if (!allowed.includes("OPTIONS")) { @@ -40,7 +41,7 @@ export function respondUnallowedMethod( res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Access-Control-Allow-Credentials", "true"); } else { - res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Origin", allowedOrigins); } res.setHeader("Access-Control-Allow-Methods", allowed); res.setHeader("Access-Control-Allow-Headers", "content-type, authorization, *"); @@ -91,7 +92,12 @@ export function securitySchemeToHttpHeader(scheme: string): string { return first.toUpperCase() + rest.join("").toLowerCase(); } -export function setCorsForThing(req: IncomingMessage, res: ServerResponse, thing: ExposedThing): void { +export function setCorsForThing( + req: IncomingMessage, + res: ServerResponse, + thing: ExposedThing, + allowedOrigins = "*" +): void { const securityScheme = thing.securityDefinitions[Helpers.toStringArray(thing.security)[0]].scheme; // Set CORS headers @@ -100,6 +106,6 @@ export function setCorsForThing(req: IncomingMessage, res: ServerResponse, thing res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Access-Control-Allow-Credentials", "true"); } else { - res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Origin", allowedOrigins); } } diff --git a/packages/binding-http/src/routes/event.ts b/packages/binding-http/src/routes/event.ts index 91a29de3b..96ca90930 100644 --- a/packages/binding-http/src/routes/event.ts +++ b/packages/binding-http/src/routes/event.ts @@ -47,7 +47,7 @@ export default async function eventRoute( return; } // TODO: refactor this part to move into a common place - setCorsForThing(req, res, thing); + setCorsForThing(req, res, thing, this.getAllowedOrigins()); let corsPreflightWithCredentials = false; const securityScheme = thing.securityDefinitions[Helpers.toStringArray(thing.security)[0]].scheme; @@ -109,7 +109,7 @@ export default async function eventRoute( } else { // may have been OPTIONS that failed the credentials check // as a result, we pass corsPreflightWithCredentials - respondUnallowedMethod(req, res, "GET", corsPreflightWithCredentials); + respondUnallowedMethod(req, res, "GET", corsPreflightWithCredentials, this.getAllowedOrigins()); } // resource found and response sent } diff --git a/packages/binding-http/src/routes/properties.ts b/packages/binding-http/src/routes/properties.ts index 970085ab9..c894c9658 100644 --- a/packages/binding-http/src/routes/properties.ts +++ b/packages/binding-http/src/routes/properties.ts @@ -39,7 +39,7 @@ export default async function propertiesRoute( } // TODO: refactor this part to move into a common place - setCorsForThing(req, res, thing); + setCorsForThing(req, res, thing, this.getAllowedOrigins()); let corsPreflightWithCredentials = false; const securityScheme = thing.securityDefinitions[Helpers.toStringArray(thing.security)[0]].scheme; @@ -86,6 +86,6 @@ export default async function propertiesRoute( } else { // may have been OPTIONS that failed the credentials check // as a result, we pass corsPreflightWithCredentials - respondUnallowedMethod(req, res, "GET", corsPreflightWithCredentials); + respondUnallowedMethod(req, res, "GET", corsPreflightWithCredentials, this.getAllowedOrigins()); } } diff --git a/packages/binding-http/src/routes/property-observe.ts b/packages/binding-http/src/routes/property-observe.ts index 1c2aac2cc..d61e704af 100644 --- a/packages/binding-http/src/routes/property-observe.ts +++ b/packages/binding-http/src/routes/property-observe.ts @@ -57,7 +57,7 @@ export default async function propertyObserveRoute( } // TODO: refactor this part to move into a common place - setCorsForThing(req, res, thing); + setCorsForThing(req, res, thing, this.getAllowedOrigins()); let corsPreflightWithCredentials = false; const securityScheme = thing.securityDefinitions[Helpers.toStringArray(thing.security)[0]].scheme; @@ -113,6 +113,6 @@ export default async function propertyObserveRoute( res.writeHead(202); res.end(); } else { - respondUnallowedMethod(req, res, "GET", corsPreflightWithCredentials); + respondUnallowedMethod(req, res, "GET", corsPreflightWithCredentials, this.getAllowedOrigins()); } } diff --git a/packages/binding-http/src/routes/property.ts b/packages/binding-http/src/routes/property.ts index b0fcadbf5..d87275f53 100644 --- a/packages/binding-http/src/routes/property.ts +++ b/packages/binding-http/src/routes/property.ts @@ -77,7 +77,7 @@ export default async function propertyRoute( } // TODO: refactor this part to move into a common place - setCorsForThing(req, res, thing); + setCorsForThing(req, res, thing, this.getAllowedOrigins()); let corsPreflightWithCredentials = false; const securityScheme = thing.securityDefinitions[Helpers.toStringArray(thing.security)[0]].scheme; @@ -108,7 +108,7 @@ export default async function propertyRoute( } else if (req.method === "PUT") { const readOnly: boolean = property.readOnly ?? false; if (readOnly) { - respondUnallowedMethod(req, res, "GET, PUT"); + respondUnallowedMethod(req, res, "GET, PUT", false, this.getAllowedOrigins()); return; } @@ -128,6 +128,6 @@ export default async function propertyRoute( } else { // may have been OPTIONS that failed the credentials check // as a result, we pass corsPreflightWithCredentials - respondUnallowedMethod(req, res, "GET, PUT", corsPreflightWithCredentials); + respondUnallowedMethod(req, res, "GET, PUT", corsPreflightWithCredentials, this.getAllowedOrigins()); } // Property exists? } diff --git a/packages/binding-http/src/routes/thing-description.ts b/packages/binding-http/src/routes/thing-description.ts index 161c1b2c1..795d84159 100644 --- a/packages/binding-http/src/routes/thing-description.ts +++ b/packages/binding-http/src/routes/thing-description.ts @@ -169,7 +169,7 @@ export default async function thingDescriptionRoute( const payload = await content.toBuffer(); negotiateLanguage(td, thing, req); - res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Origin", this.getAllowedOrigins()); res.setHeader("Content-Type", contentType); res.writeHead(200); debug(`Sending HTTP response for TD with Content-Type ${contentType}.`); diff --git a/packages/binding-http/src/routes/things.ts b/packages/binding-http/src/routes/things.ts index 93d4aa162..f6a19dfdb 100644 --- a/packages/binding-http/src/routes/things.ts +++ b/packages/binding-http/src/routes/things.ts @@ -22,7 +22,7 @@ export default function thingsRoute( res: ServerResponse, _params: unknown ): void { - res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Origin", this.getAllowedOrigins()); res.setHeader("Content-Type", ContentSerdes.DEFAULT); res.writeHead(200); const list = []; diff --git a/packages/binding-http/test/http-server-cors-test.ts b/packages/binding-http/test/http-server-cors-test.ts index 3feeada8e..c0713d74a 100644 --- a/packages/binding-http/test/http-server-cors-test.ts +++ b/packages/binding-http/test/http-server-cors-test.ts @@ -250,3 +250,252 @@ class HttpServerCorsTest { expect(response.headers.get("Access-Control-Allow-Origin")).to.equal("*"); } } + +@suite("HTTP Server CORS with allowedOrigins config") +class HttpServerCorsAllowedOriginsTest { + @test async "should use configured allowedOrigins for nosec things"() { + const servient = new Servient(); + const httpServer = new HttpServer({ port: 0, allowedOrigins: "http://my-app.example.com" }); + await httpServer.start(servient); + + try { + const thing = new ExposedThing(servient, { + title: "TestOriginConfig", + properties: { + test: { + type: "string", + forms: [], + }, + }, + }); + + thing.setPropertyReadHandler("test", () => Promise.resolve("value")); + await httpServer.expose(thing); + + const uri = `http://localhost:${httpServer.getPort()}/testoriginconfig/properties/test`; + const response = await fetch(uri, { + headers: { Origin: "http://other.example.com" }, + }); + + expect(response.status).to.equal(200); + expect(response.headers.get("Access-Control-Allow-Origin")).to.equal("http://my-app.example.com"); + } finally { + await httpServer.stop(); + } + } + + @test async "should use configured allowedOrigins for thing listing"() { + const servient = new Servient(); + const httpServer = new HttpServer({ port: 0, allowedOrigins: "http://my-app.example.com" }); + await httpServer.start(servient); + + try { + const uri = `http://localhost:${httpServer.getPort()}/`; + const response = await fetch(uri); + + expect(response.status).to.equal(200); + expect(response.headers.get("Access-Control-Allow-Origin")).to.equal("http://my-app.example.com"); + } finally { + await httpServer.stop(); + } + } + + @test async "should use configured allowedOrigins for thing description"() { + const servient = new Servient(); + const httpServer = new HttpServer({ port: 0, allowedOrigins: "http://my-app.example.com" }); + await httpServer.start(servient); + + try { + const thing = new ExposedThing(servient, { + title: "TestOriginTD", + properties: { + test: { + type: "string", + forms: [], + }, + }, + }); + + await httpServer.expose(thing); + + const uri = `http://localhost:${httpServer.getPort()}/testorigintd`; + const response = await fetch(uri, { + headers: { Accept: "application/td+json" }, + }); + + expect(response.status).to.equal(200); + expect(response.headers.get("Access-Control-Allow-Origin")).to.equal("http://my-app.example.com"); + } finally { + await httpServer.stop(); + } + } + + @test async "should use configured allowedOrigins in CORS preflight for nosec"() { + const servient = new Servient(); + const httpServer = new HttpServer({ port: 0, allowedOrigins: "http://my-app.example.com" }); + await httpServer.start(servient); + + try { + const thing = new ExposedThing(servient, { + title: "TestOriginPreflight", + properties: { + test: { + type: "string", + forms: [], + }, + }, + }); + + await httpServer.expose(thing); + + const uri = `http://localhost:${httpServer.getPort()}/testoriginpreflight/properties/test`; + const response = await fetch(uri, { + method: "OPTIONS", + headers: { + Origin: "http://other.example.com", + "Access-Control-Request-Method": "GET", + }, + }); + + expect(response.status).to.equal(200); + expect(response.headers.get("Access-Control-Allow-Origin")).to.equal("http://my-app.example.com"); + } finally { + await httpServer.stop(); + } + } + + @test async "should default to * when allowedOrigins is not configured"() { + const servient = new Servient(); + const httpServer = new HttpServer({ port: 0 }); + await httpServer.start(servient); + + try { + const thing = new ExposedThing(servient, { + title: "TestOriginDefault", + properties: { + test: { + type: "string", + forms: [], + }, + }, + }); + + thing.setPropertyReadHandler("test", () => Promise.resolve("value")); + await httpServer.expose(thing); + + const uri = `http://localhost:${httpServer.getPort()}/testorigindefault/properties/test`; + const response = await fetch(uri, { + headers: { Origin: "http://any.example.com" }, + }); + + expect(response.status).to.equal(200); + expect(response.headers.get("Access-Control-Allow-Origin")).to.equal("*"); + } finally { + await httpServer.stop(); + } + } + + @test async "should use configured allowedOrigins for invoke action (POST)"() { + const servient = new Servient(); + const httpServer = new HttpServer({ port: 0, allowedOrigins: "http://my-app.example.com" }); + await httpServer.start(servient); + + try { + const thing = new ExposedThing(servient, { + title: "TestOriginAction", + actions: { + doSomething: { + forms: [], + }, + }, + }); + + thing.setActionHandler("doSomething", () => Promise.resolve(undefined)); + await httpServer.expose(thing); + + const uri = `http://localhost:${httpServer.getPort()}/testoriginaction/actions/doSomething`; + const response = await fetch(uri, { + method: "POST", + headers: { Origin: "http://other.example.com" }, + }); + + expect(response.status).to.equal(204); + expect(response.headers.get("Access-Control-Allow-Origin")).to.equal("http://my-app.example.com"); + } finally { + await httpServer.stop(); + } + } + + @test async "should use configured allowedOrigins for subscribe event (GET)"() { + const servient = new Servient(); + const httpServer = new HttpServer({ port: 0, allowedOrigins: "http://my-app.example.com" }); + await httpServer.start(servient); + + try { + const thing = new ExposedThing(servient, { + title: "TestOriginEvent", + events: { + onChange: { + forms: [], + }, + }, + }); + + await httpServer.expose(thing); + + const uri = `http://localhost:${httpServer.getPort()}/testoriginevent/events/onChange`; + const response = await fetch(uri, { + method: "OPTIONS", + headers: { + Origin: "http://other.example.com", + "Access-Control-Request-Method": "GET", + }, + }); + + expect(response.status).to.equal(200); + expect(response.headers.get("Access-Control-Allow-Origin")).to.equal("http://my-app.example.com"); + } finally { + await httpServer.stop(); + } + } + + @test async "should still use origin for secured things regardless of config"() { + const servient = new Servient(); + const httpServer = new HttpServer({ + port: 0, + allowedOrigins: "http://my-app.example.com", + security: [{ scheme: "basic" }], + }); + await httpServer.start(servient); + + try { + const thing = new ExposedThing(servient, { + title: "TestOriginSecured", + securityDefinitions: { + basic_sc: { scheme: "basic" }, + }, + security: ["basic_sc"], + properties: { + test: { + type: "string", + forms: [], + }, + }, + }); + + await httpServer.expose(thing); + + const uri = `http://localhost:${httpServer.getPort()}/testoriginsecured/properties/test`; + const response = await fetch(uri, { + headers: { Origin: "http://caller.example.com" }, + }); + + // Security scheme is basic, so origin should be echoed back (not allowedOrigins) + expect(response.status).to.equal(401); + expect(response.headers.get("Access-Control-Allow-Origin")).to.equal("http://caller.example.com"); + expect(response.headers.get("Access-Control-Allow-Credentials")).to.equal("true"); + } finally { + await httpServer.stop(); + } + } +}