controller

# What is Controller

The previous chapter says router is mainly used to describe the relationship between the request URL and the Controller that processes the request eventually, so what is a Controller used for?

Simply speaking, a Controller is used for parsing user's input and send back the relative result after processing, for example:

  • In RESTful interfaces, Controller accepts parameters from users and sends selected results back to user or modifies data in the database.
  • In the HTML page requests, Controller renders related templates to HTML according to different URLs requested and then sends back to users.
  • In the proxy servers, Controller transfers user's requests to other servers and sends back process results to users in return.

The framework recommends that the Controller layer is responsible for processing request parameters(verification and transformation) from user's requests, then calls related business methods in Service, encapsulates and sends back business result:

  1. retrieves parameters passed by HTTP.
  2. verifies and assembles parameters.
  3. calls the Service to handle business, if necessary, transforms Service process results to satisfy user's requirement.
  4. sends results back to user by HTTP.

# How To Write Controller

All Controller files must be put under app/controller directory, which can support multi-level directory. when accessing, cascading access can be done through directory names. Controllers can be written in various patterns depending on various project scenarios and development styles.

# Controller Class(Recommended)

You can write a Controller by defining a Controller class:

// app/controller/post.js
const Controller = require('egg').Controller;
class PostController extends Controller {
async create() {
const { ctx, service } = this;
const createRule = {
title: { type: 'string' },
content: { type: 'string' },
};
// verify parameters
ctx.validate(createRule);
// assemble parameters
const author = ctx.session.userId;
const req = Object.assign(ctx.request.body, { author });
// calls Service to handle business
const res = await service.post.create(req);
// set response content and status code
ctx.body = { id: res.id };
ctx.status = 201;
}
}
module.exports = PostController;

We've defined a PostController class above and every method of this Controller can be used in Router, we can locate it from app.controller according to the file name and the method name.

// app/router.js
module.exports = app => {
const { router, controller } = app;
router.post('createPost', '/api/posts', controller.post.create);
}

Multi-level directory is supported, for example, put the above code into app/controller/sub/post.js, then we could mount it by:

// app/router.js
module.exports = app => {
app.router.post('createPost', '/api/posts', app.controller.sub.post.create);
}

The defined Controller class will initialize a new object for every request when accessing the server, and some of the following attributes will be attached to this since the Controller classes in the project are inherited from egg.Controller.

  • this.ctx: the instance of Context for current request, through which we can access many attributes and methods encapsulated by the framework to handle current request conveniently.
  • this.app: the instance of Application for current request, through which we can access global objects and methods provided by the framework.
  • this.service: Service defined by the application, through which we can access the abstract business layer, equivalent to this.ctx.service.
  • this.config: the application's run-time config.
  • this.logger:logger with debuginfowarnerror, use to print different level log, is almost the same as context logger, but it will append Controller file path for quickly track.

# Customized Controller Base Class

Defining a Controller class helps us not only abstract the Controller layer codes better(e.g. some unified processing can be abstracted as private) but also encapsulate methods that are widely used in the application by defining a customized Controller base class.

// app/core/base_controller.js
const { Controller } = require('egg');
class BaseController extends Controller {
get user() {
return this.ctx.session.user;
}

success(data) {
this.ctx.body = {
success: true,
data,
};
}

notFound(msg) {
msg = msg || 'not found';
this.ctx.throw(404, msg);
}
}
module.exports = BaseController;

Now we can use base class' methods by inheriting from BaseController:

//app/controller/post.js
const Controller = require('../core/base_controller');
class PostController extends Controller {
async list() {
const posts = await this.service.listByUser(this.user);
this.success(posts);
}
}

Every Controller is an async function, whose argument is the instance of the request Context and through which we can access many attributes and methods encapsulated by the framework conveniently.

For example, when we define a Controller relative to POST /api/posts, we create a post.js file under app/controller directory.

// app/controller/post.js
exports.create = async ctx => {
const createRule = {
title: { type: 'string' },
content: { type: 'string' },
};
// verify parameters
ctx.validate(createRule);
// assemble parameters
const author = ctx.session.userId;
const req = Object.assign(ctx.request.body, { author });
// calls Service to handle business
const res = await ctx.service.post.create(req);
// set response content and status code
ctx.body = { id: res.id };
ctx.status = 201;
};

In the above example, we introduce some new concepts, however it's still intuitive and understandable. We'll explain these new concepts in detail soon.

# HTTP Basics

Since Controller is probably the only place to interact with HTTP protocol when developing business logics, it's necessary to have a quick look at how HTTP protocol works before going on.

If we send a HTTP request to access the previous example Controller:

curl -X POST http://localhost:3000/api/posts --data '{"title":"controller", "content": "what is controller"}' --header 'Content-Type:application/json; charset=UTF-8'

The HTTP request sent by curl looks like this:

POST /api/posts HTTP/1.1
Host: localhost:3000
Content-Type: application/json; charset=UTF-8

{"title": "controller", "content": "what is controller"}

The first line of the request contains three information, first two of which are commonly used:

  • method: it's POST in this example.
  • path: it's /api/posts, if the user's request contains query, it will also be placed here.

From the second line to the place where the first empty line appears is the Header part of the request which includes many useful attributes. as you may see, Host, Content-Type and Cookie, User-Agent, etc. There are two headers in this request:

  • Host: when we send a request in the browser, the domain is resolved to server IP by DNS and, as well, the domain and port are sent to the server in the Host header by the browser.
  • Content-Type: when we have a body in our request, the Content-Type is provided to describe the type of our request body.

The whole following content is the request body, which can be brought by POST, PUT, DELETE and other methods. and the server resolves the request body according to Content-Type.

When the sever finishes to process the request, a HTTP response is sent back to the client:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 8
Date: Mon, 09 Jan 2017 08:40:28 GMT
Connection: keep-alive

{"id": 1}

The first line contains three segments, among which the status code is used mostly, in this case, it's 201 which means the server has created a resource successfully.

Similar to the request, the header part begins at the second line and ends at the place where the next empty line appears, in this case, they are Content-Type and Content-Length indicating the response format is JSON and the length is 8 bytes.

The remaining part is the actual content of this response.

# Acquire HTTP Request Parameters

It can be seen from the above HTTP request examples that there are many places can be used to put user's request data. The framework provides many convenient methods and attributes by binding the Context instance to Controllers to acquire parameters sent by users through HTTP request.

# query

Usually the Query String, string following ? in the URL, is used to send parameters by request of GET type. For example, category=egg&language=node in GET /posts?category=egg&language=node is the parameter that user sends. We can acquire this parsed parameter body through ctx.query:

class PostController extends Controller {
async listPosts() {
const query = this.ctx.query;
// {
// category: 'egg',
// language: 'node',
// }
}
}

If duplicated keys exist in Query String, only the first value of this key is used by ctx.query and the subsequent appearance will be omitted. That is to say, for request GET /posts?category=egg&category=koa, what ctx.query acquires is { category: 'egg' }.

This is for unity reason, because we usually do not design users to pass parameters with same keys in Query String then we write codes like below:

const key = ctx.query.key || '';
if (key.startsWith('egg')) {
// do something
}

Or if someone passes parameters with same keys in Query String on purpose, system error may be thrown. To avoid this, the framework guarantee that the parameter must be a string type whenever it is acquired from ctx.query.

# queries

Sometimes our system is designed to accept same keys sent by users, like GET /posts?category=egg&id=1&id=2&id=3. For this situation, the framework provides ctx.queries object to parse Query String and put duplicated data into an array:

// GET /posts?category=egg&id=1&id=2&id=3
class PostController extends Controller {
async listPosts() {
console.log(this.ctx.queries);
// {
// category: [ 'egg' ],
// id: [ '1', '2', '3' ],
// }
}
}

All key on the ctx.queries will be an array type if it has a value.

# Router params

In Router part, we say Router is allowed to declare parameters which can be acquired by ctx.params.

// app.get('/projects/:projectId/app/:appId', 'app.listApp');
// GET /projects/1/app/2
class AppController extends Controller {
async listApp() {
assert.equal(this.ctx.params.projectId, '1');
assert.equal(this.ctx.params.appId, '2');
}
}

# body

Although we can pass parameters through URL, but constraints exist:

In the above HTTP request message example, we can learn, following the header, there's a body part that can be used to put parameters for POST, PUT and DELETE, etc. The Content-Type will be sent by clients(browser) in the same time to tell the server the type of request body when there is a body in a general request. Two mostly used data formats are JSON and Form in Web developing for transferring data.

The bodyParser middleware is built in by the framework to parse the request body of these two kinds of formats into an object mounted to ctx.request.body. Since it's not recommended by the HTTP protocol to pass a body by GET and HEAD methods, ctx.request.body cannot be used for GET and HEAD methods.

// POST /api/posts HTTP/1.1
// Host: localhost:3000
// Content-Type: application/json; charset=UTF-8
//
// {"title": "controller", "content": "what is controller"}
class PostController extends Controller {
async listPosts() {
assert.equal(this.ctx.request.body.title, 'controller');
assert.equal(this.ctx.request.body.content, 'what is controller');
}
}

The framework configures some default parameters for bodyParser and has the following features:

  • when Content-Type is application/json, application/json-patch+json, application/vnd.api+json and application/csp-report, it parses the request body as JSON format and limits the maximum length of the body down to 100kb.
  • when Content-Type is application/x-www-form-urlencoded, it parses the request body as Form format and limits the maximum length of the body down to 100kb.
  • when parses successfully, the body must be an Object(also can be an array).

The mostly adjusted config field is the maximum length of the request body for parsing which can be configured in config/config.default.js to overwrite the default value of the framework:

module.exports = {
bodyParser: {
jsonLimit: '1mb',
formLimit: '1mb',
},
};

If user request exceeds the maximum length for parsing that we configured, the framework will throw an exception whose status code is 413; if request body fails to be parsed(e.g. wrong JSON), an exception with status code 400 will be thrown.

Note: when adjusting the maximum length of the body for bodyParser, if we have a reverse proxy(Nginx) in front of our application, we may need to adjust its configuration, so that the reverse proxy also supports the same length of request body.

A common mistake is to confuse ctx.request.body and ctx.body(which is alias for ctx.response.body).

# Acquire the submitted files

The body in the request can carry parameters as well as files. Generally speaking, our browsers always send files in multipart/form-data, and we now have two kinds of ways supporting submitting and acquiring files with the help of the framework's plug-in Multipart.

  • # File Mode:

If you have no ideas about Nodejs's Stream at all, the File mode suits you well:

  1. In your config file, enable file mode first:
// config/config.default.js
exports.multipart = {
mode: 'file',
};
  1. Submitting / Acquiring files:
  1. For single file:

Your HTML static front-end codes should look like this below:

<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
title: <input name="title" />
file: <input name="file" type="file" />
<button type="submit">Upload</button>
</form>

The corresponding backend codes are:

// app/controller/upload.js
const Controller = require('egg').Controller;
const fs = require('mz/fs');

module.exports = class extends Controller {
async upload() {
const { ctx } = this;
const file = ctx.request.files[0];
const name = 'egg-multipart-test/' + path.basename(file.filename);
let result;
try {
// process file (e.g: upload to cloud storage)
result = await ctx.oss.put(name, file.filepath);
} finally {
// need to remove the tmp file
await fs.unlink(file.filepath);
}

ctx.body = {
url: result.url,
// get all field values
requestBody: ctx.request.body,
};
}
};
  1. For multiple files:

For multiple files, with the help of ctx.request.files, we can loop each of them and do what process we like:

Your HTML static front-end codes should look like this below:

<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
title: <input name="title" />
file1: <input name="file1" type="file" />
file2: <input name="file2" type="file" />
<button type="submit">Upload</button>
</form>

The corresponding backend codes are:

// app/controller/upload.js
const Controller = require('egg').Controller;
const fs = require('mz/fs');

module.exports = class extends Controller {
async upload() {
const { ctx } = this;
console.log(ctx.request.body);
console.log('got %d files', ctx.request.files.length);
for (const file of ctx.request.files) {
console.log('field: ' + file.fieldname);
console.log('filename: ' + file.filename);
console.log('encoding: ' + file.encoding);
console.log('mime: ' + file.mime);
console.log('tmp filepath: ' + file.filepath);
let result;
try {
// process file (e.g: upload to cloud storage)
result = await ctx.oss.put('egg-multipart-test/' + file.filename, file.filepath);
} finally {
// need to remove the tmp file
await fs.unlink(file.filepath);
}
console.log(result);
}
}
};
  • # Stream Mode

If you are very familiar with Stream in Nodejs, you can choose this way. In a controller, we can fetch the uploaded files through ctx.getFileStream().

  1. For single file:
<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
title: <input name="title" />
file: <input name="file" type="file" />
<button type="submit">Upload</button>
</form>
const path = require('path');
const sendToWormhole = require('stream-wormhole');
const Controller = require('egg').Controller;

class UploaderController extends Controller {
async upload() {
const ctx = this.ctx;
const stream = await ctx.getFileStream();
const name = 'egg-multipart-test/' + path.basename(stream.filename);
let result;
try {
// process file (e.g: upload to cloud storage)
result = await ctx.oss.put(name, stream);
} catch (err) {
// You MUST consume the file stream, otherwises the browser cannot response any more
await sendToWormhole(stream);
throw err;
}

ctx.body = {
url: result.url,
// All the fields in the form can be fetched through `stream.fields`
fields: stream.fields,
};
}
}

module.exports = UploaderController;

To acquire the uploaded files easily, there're two conditions at least:

  • Only ONE file per time.
  • The field of uploading file MUST be after the other fields in a form, otherwise you cannot get other fields after getting the file stream.
  1. For multiple files:

For multiple files, you should do the following instead of using ctx.getFileStream():

const sendToWormhole = require('stream-wormhole');
const Controller = require('egg').Controller;

class UploaderController extends Controller {
async upload() {
const ctx = this.ctx;
const parts = ctx.multipart();
let part;
// parts() return a promise
while ((part = await parts()) != null) {
if (part.length) {
// arrays are busboy fields
console.log('field: ' + part[0]);
console.log('value: ' + part[1]);
console.log('valueTruncated: ' + part[2]);
console.log('fieldnameTruncated: ' + part[3]);
} else {
if (!part.filename) {
// When a user clicks `upload` before choosing a file,
// `part` will be file stream, but `part.filename` is empty.
// We must handler this by notifying the user that he/she should
// choose a file before submitting
return;
}
// otherwise, it's a fully-filled stream
console.log('field: ' + part.fieldname);
console.log('filename: ' + part.filename);
console.log('encoding: ' + part.encoding);
console.log('mime: ' + part.mime);
let result;
try {
// process file (e.g: upload to cloud storage)
result = await ctx.oss.put('egg-multipart-test/' + part.filename, part);
} catch (err) {
// You MUST consume the file stream, otherwises the browser cannot response any more
await sendToWormhole(part);
throw err;
}
console.log(result);
}
}
console.log('and we are done parsing the form!');
}
}

module.exports = UploaderController;

The framework also has the limits for the safety of uploading files, the default white list is:

// images
'.jpg', '.jpeg', // image/jpeg
'.png', // image/png, image/x-png
'.gif', // image/gif
'.bmp', // image/bmp
'.wbmp', // image/vnd.wap.wbmp
'.webp',
'.tif',
'.psd',
// text
'.svg',
'.js', '.jsx',
'.json',
'.css', '.less',
'.html', '.htm',
'.xml',
// tar
'.zip',
'.gz', '.tgz', '.gzip',
// video
'.mp3',
'.mp4',
'.avi',

Users can add new file extensions in config/config.default.js, or rewrite a whole white list:

  • Newly-added a file extension:
module.exports = {
multipart: {
fileExtensions: [ '.apk' ], // Add support for apk files
},
};
  • Overwriting a whole white list:
module.exports = {
multipart: {
whitelist: [ '.png' ] // ONLY files of png is allowed
},
};

Notice:fileExtensions will be IGNORED when whitelist is overwritten.

For more tech details about this, please refer Egg-Multipart.

Apart from URL and request body, some parameters can be sent by request header. The framework provides some helper attributes and methods to retrieve them.

  • ctx.headers, ctx.header, ctx.request.headers, ctx.request.header: these methods are equivalent and all of them get the whole header object.
  • ctx.get(name), ctx.request.get(name): get the value of one parameter from the request header, if the parameter does not exist, an empty string will be returned.
  • We recommend you use ctx.get(name) rather than ctx.headers['name'] because the former handles upper/lower case automatically.

Since header is special, some of which are given specific meanings by the HTTP protocol (like Content-Type, Accept), some are set by the reverse proxy as a convention (X-Forwarded-For), and the framework provides some convenient getters for them as well, for more details please refer to API.

Specially when we set config.proxy = true to deploy the application behind the reverse proxy (Nginx), some Getters' internal process may be changed.

# ctx.host

Reads the header's value configured by config.hostHeaders firstly, if fails, then it tries to get the value of host header, if fails again, it returns an empty string.

config.hostHeaders defaults to x-forwarded-host.

# ctx.protocol

When you get protocol through this Getter, it checks whether current connection is an encrypted one or not, if it is, it returns HTTPS.

When current connection is not an encrypted one, it reads the header's value configured by config.protocolHeaders to check HTTP or HTTPS, if it fails, we can set a safe-value(defaults to HTTP) through config.protocol in the configuration.

config.protocolHeaders defaults to x-forwarded-proto.

# ctx.ips

A IP address list of all intermediate equipments that a request go through can be get by ctx.ips, only when config.proxy = true, it reads the header's value configured by config.ipHeaders instead, if fails, it returns an empty array.

config.ipHeaders defaults to x-forwarded-for.

# ctx.ip

The IP address of the sender of the request can be get by ctx.ip, it reads from ctx.ips firstly, if ctx.ips is empty, it returns the connection sender's IP address.

Note: ip and ips are different, if config.proxy = false, ip returns the connection sender's IP address while ips returns an empty array.

All HTTP requests are stateless but, on the contrary, our Web applications usually need to know who sends the requests. To make it through, the HTTP protocol designs a special request header: Cookie. With the response header (set-cookie), the server is able to send a few data to the client, the browser saves these data according to the protocol and brings them along with the next request(according to the protocol and for safety reasons, only when accessing websites that match the rules specified by Cookie does the browser bring related Cookies).

Through ctx.cookies, we can conveniently and safely set and get Cookie in Controller.

class CookieController extends Controller {
async add() {
const ctx = this.ctx;
const count = ctx.cookies.get('count');
count = count ? Number(count) : 0;
ctx.cookies.set('count', ++count);
ctx.body = count;
}

async remove() {
const ctx = this.ctx;
const count = ctx.cookies.set('count', null);
ctx.status = 204;
}
}

Although Cookie is only a header in HTTP, multiple key-value pairs can be set in the format of foo=bar;foo1=bar1;.

In Web applications, Cookie is usually used to send the identity information of the client, so it has many safety related configurations which can not be ignored, Cookie explains the usage and safety related configurations of Cookie in detail and is worth being read in depth.

# Session

By using Cookie, we can create an individual Session specific to every user to store user identity information, which will be encrypted then stored in Cookie to perform session persistence across requests.

The framework builds in Session plugin, which provides ctx.session for us to get or set current user's Session.

class PostController extends Controller {
async fetchPosts() {
const ctx = this.ctx;
// get data from Session
const userId = ctx.session.userId;
const posts = await ctx.service.post.fetch(userId);
// set value to Session
ctx.session.visited = ctx.session.visited ? ++ctx.session.visited : 1;
ctx.body = {
success: true,
posts,
};
}
}

It's quite intuitional to use Session, just get or set directly, if you want to delete it, you can assign it to null:

class SessionController extends Controller {
async deleteSession() {
this.ctx.session = null;
}
};

Like Cookie, Session has many safety related configurations and functions etc., so it's better to read Session in depth in ahead.

# Configuration

There are mainly these attributes below can be used to configure Session in config.default.js:

module.exports = {
key: 'EGG_SESS', // the name of key-value pairs, which is specially used by Cookie to store Session
maxAge: 86400000, // Session maximum valid time
};

# Parameter Validation

After getting parameters from user requests, in most cases, it is inevitable to validate these parameters.

With the help of the convenient parameter validation mechanism provided by Validate plugin, with which we can do all kinds of complex parameter validations.

// config/plugin.js
exports.validate = {
enable: true,
package: 'egg-validate',
};

Validate parameters directly through ctx.validate(rule, [body]):

class PostController extends Controller {
async create() {
// validate parameters
// if the second parameter is absent, `ctx.request.body` is validated automatically
this.ctx.validate({
title: { type: 'string' },
content: { type: 'string' },
});
}
}

When the validation fails, an exception will be thrown immediately with an error code of 422 and an errors field containing the detailed information why it fails. You can capture this exception through try catch and handle it all by yourself.

class PostController extends Controller {
async create() {
const ctx = this.ctx;
try {
ctx.validate(createRule);
} catch (err) {
ctx.logger.warn(err.errors);
ctx.body = { success: false };
return;
}
}
};

# Validation Rules

The parameter validation is done by Parameter, and all supported validation rules can be found in its document.

# Customizing validation rules

In addition to built-in validation types introduced in the previous section, sometimes we hope to customize several validation rules to make the development more convenient and now customized rules can be added through app.validator.addRule(type, check).

// app.js
app.validator.addRule('json', (rule, value) => {
try {
JSON.parse(value);
} catch (err) {
return 'must be json string';
}
});

After adding the customized rule, it can be used immediately in Controller to do parameter validation.

class PostController extends Controller {
async handler() {
const ctx = this.ctx;
// query.test field must be a json string
const rule = { test: 'json' };
ctx.validate(rule, ctx.query);
}
}

# Using Service

We do not prefer to implement too many business logics in Controller, so a Service layer is provided to encapsulate business logics, which not only increases the reusability of codes but also makes it easy for us to test our business logics.

In Controller, any method of any Service can be called and, in the meanwhile, Service is lazy loaded which means it is only initialized by the framework on the first time it is accessed.

class PostController extends Controller {
async create() {
const ctx = this.ctx;
const author = ctx.session.userId;
const req = Object.assign(ctx.request.body, { author });
// using service to handle business logics
const res = await ctx.service.post.create(req);
ctx.body = { id: res.id };
ctx.status = 201;
}
};

To write a Service in detail, please refer to Service.

# Sending HTTP Response

After business logics are handled, the last thing Controller should do is to send the processing result to users with an HTTP response.

# Setting Status

HTTP designs many Status Code, each of which indicates a specific meaning, and setting the status code correctly makes the response more semantic.

The framework provides a convenient Setter to set the status code:

class PostController extends Controller {
async create() {
// set status code to 201
this.ctx.status = 201;
}
}

As to which status code should be used for a specific case, please refer to status code meanings on List of HTTP status codes

# Setting Body

Most data is sent to requesters through the body and, just like the body in the request, the body sent by the response demands a set of corresponding Content-Type to inform clients how to parse data.

  • for a RESTful API controller, we usually send a body whose Content-Type is application/json, indicating it's a JSON string.
  • for a HTML page controller, we usually send a body whose Content-Type is text/html, indicating it's a piece of HTML code.

Note: ctx.body is alias of ctx.response.body, don't confuse with ctx.request.body.

class ViewController extends Controller {
async show() {
this.ctx.body = {
name: 'egg',
category: 'framework',
language: 'Node.js',
};
}

async page() {
this.ctx.body = '<html><h1>Hello</h1></html>';
}
}

Due to the Stream feature of Node.js, we need to return the response by Stream in some cases, e.g., returning a big file, the proxy server returns content from upstream straightforward, the framework also supports setting the body into a Stream directly and handling error events on this stream well in the meanwhile.

class ProxyController extends Controller {
async proxy() {
const ctx = this.ctx;
const result = await ctx.curl(url, {
streaming: true,
});
ctx.set(result.header);
// result.res is stream
ctx.body = result.res;
}
}

# Rendering Template

Usually we do not write HTML pages by hand, instead we generate them by a template engine. Egg itself does not integrate any template engine, but it establishes the View Plugin Specification. Once the template engine is loaded, ctx.render(template) can be used to render templates to HTML directly.

class HomeController extends Controller {
async index() {
const ctx = this.ctx;
await ctx.render('home.tpl', { name: 'egg' });
// ctx.body = await ctx.renderString('hi, {{ name }}', { name: 'egg' });
}
};

For detailed examples, please refer to Template Rendering.

# JSONP

Sometimes we need to provide API services for pages in a different domain, and, for historical reasons, CORS fails to make it through, while JSONP does.

Since misuse of JSONP leads to dozens of security issues, the framework supplies a convenient way to respond JSONP data, encapsulating JSONP XSS Related Security Precautions, and supporting the validation of CSRF and referrer.

  • app.jsonp() provides a middleware for the controller to respond JSONP data. We may add this middleware to the router that needs to support jsonp:
// app/router.js
module.exports = app => {
const jsonp = app.jsonp();
app.router.get('/api/posts/:id', jsonp, app.controller.posts.show);
app.router.get('/api/posts', jsonp, app.controller.posts.list);
};
  • We just program as usual in the Controller:
// app/controller/posts.js
class PostController extends Controller {
async show() {
this.ctx.body = {
name: 'egg',
category: 'framework',
language: 'Node.js',
};
}
}

When user's requests access this controller through a corresponding URL, if the query contains the _callback=fn parameter, data is returned in JSONP format, otherwise in JSON format.

# JSONP Configuration

By default, the framework determines whether to return data in JSONP format or not depending on the _callback parameter in the query, and the method name set by _callback must be less than 50 characters. Applications may overwrite the default configuration globally in config/config.default.js:

// config/config.default.js
exports.jsonp = {
callback: 'callback', // inspecting the `callback` parameter in the query
limit: 100, // the maximum size of the method name is 100 characters
};

With the configuration above, if a user requests /api/posts/1?callback=fn, a JSONP format response is sent, if /api/posts/1, a JSON format response is sent.

Also we can overwrite the default configuration in app.jsonp() when creating the middleware and therefore separate configurations is used for separate routers:

// app/router.js
module.exports = app => {
const { router, controller, jsonp } = app;
router.get('/api/posts/:id', jsonp({ callback: 'callback' }), controller.posts.show);
router.get('/api/posts', jsonp({ callback: 'cb' }), controller.posts.list);
};

# XSS Defense Configuration

By default, XSS is not defended when responding JSONP, and, in some cases, it is quite dangerous. We classify JSONP APIs into three type grossly:

  1. querying non-sensitive data, e.g. getting the public post list of a BBS.
  2. querying sensitive data, e.g. getting the transaction record of a user.
  3. submitting data and modifying the database, e.g. create a new order for a user.

If our JSONP API provides the last two type services and, without any XSS defense, user's sensitive data may be leaked and even user may be phished. Given this, the framework supports the validations of CSRF and referrer by default.

# CSRF

In the JSONP configuration, we could enable the CSRF validation for JSONP APIs simply by setting csrf: true.

// config/config.default.js
module.exports = {
jsonp: {
csrf: true,
},
};

Note: the CSRF validation depends on the Cookie based CSRF validation provided by security.

When the CSRF validation is enabled, the client should bring CSRF token as well when it sends a JSONP request, if the page where the JSONP sender belongs to shares the same domain with our services, CSRF token in Cookie can be read(CSRF can be set manually if it is absent), and is brought together with the request.

# referrer Validation

The CSRF way can be used for JSONP request validation only if the main domains are the same, while providing JSONP services for pages in different domains, we can limit JSONP senders into a controllable rang by configuring the referrer whitelist.

//config/config.default.js
exports.jsonp = {
whiteList: /^https?:\/\/test.com\//,
// whiteList: '.test.com',
// whiteList: 'sub.test.com',
// whiteList: [ 'sub.test.com', 'sub2.test.com' ],
};

whileList can be configured as regular expression, string and array:

  • Regular Expression: only requests whose Referrer match the regular expression are allowed to access the JSONP API. When composing the regular expression, please also notice the leading ^ and tail \/ which guarantees the whole domain matches.
exports.jsonp = {
whiteList: /^https?:\/\/test.com\//,
};
// matches referrer:
// https://test.com/hello
// http://test.com/
  • String: two cases exists when configuring the whitelist as a string, if the string begins with a ., e.g. .test.com, the referrer whitelist indicates all sub-domains of test.com, test.com itself included. if the string does not begin with a ., e.g. sub.test.com, it indicates sub.test.com one domain only. (both HTTP and HTTPS are supported)
exports.jsonp = {
whiteList: '.test.com',
};
// matches domain test.com:
// https://test.com/hello
// http://test.com/

// matches subdomain
// https://sub.test.com/hello
// http://sub.sub.test.com/

exports.jsonp = {
whiteList: 'sub.test.com',
};
// only matches domain sub.test.com:
// https://sub.test.com/hello
// http://sub.test.com/
  • Array: when the whitelist is configured as an array, the referrer validation is passed only if at least one condition represented by array items is matched.
exports.jsonp = {
whiteList: [ 'sub.test.com', 'sub2.test.com' ],
};
// matches domain sub.test.com and sub2.test.com:
// https://sub.test.com/hello
// http://sub2.test.com/

If both CSRF and referrer validation are enabled, the request sender passes any one of them passes the JSONP security validation.

# Setting Header

We identify whether the request is successful or not by using the status code and set response content in the body. By setting the response header, extended information can be set as well.

ctx.set(key, value) sets one response header and ctx.set(headers) sets many in one time.

// app/controller/api.js
class ProxyController extends Controller {
async show() {
const ctx = this.ctx;
const start = Date.now();
ctx.body = await ctx.service.post.get();
const used = Date.now() - start;
// set one response header
ctx.set('show-response-time', used.toString());
}
}

# Redirect

The framework overwrites koa's native ctx.redirect implementation with a security plugin to provide a more secure redirect.

  • ctx.redirect(url) Forbids redirect if it is not in the configured whitelist domain name.
  • ctx.unsafeRedirect(url) does not determine the domain name and redirect directly. Generally, it is not recommended to use it. Use it after clearly understanding the possible risks.

If you use the ctx.redirect method, you need to configure the application configuration file as follows:

// config/config.default.js
exports.security = {
domainWhiteList:['.domain.com'], // Security whitelist, starts with `.`
};

If the user does not configure the domainWhiteList or the domainWhiteList array to be empty, then all redirect requests will be released by default, which is equivalent to ctx.unsafeRedirect(url).