From Code to Community: Sponsoring The Perl and Raku Conference 2025 Learn more

use strict;
use Amon2::Util ();
use Scalar::Util qw(blessed);
our $VERSION = "0.01";
my %DEFAULT_CONFIG = (
name => 'render_json',
# for security
secure_headers => {
content_security_policy => "default-src 'none'",
strict_transport_security => 'max-age=631138519',
x_content_type_options => 'nosniff',
x_download_options => undef,
x_frame_options => 'DENY',
x_permitted_cross_domain_policies => 'none',
x_xss_protection => '1; mode=block',
referrer_policy => 'no-referrer',
},
json_escape_filter => {
'+' => '\\u002b', # do not eval as UTF-7
'<' => '\\u003c', # do not eval as HTML
'>' => '\\u003e', # ditto.
},
# JSON config
json => {
ascii => !!1, # for security
},
# for convenience
unbless_object => undef,
status_code_field => undef,
# for compatibility options
defence_json_hijacking_for_legacy_browser => !!0,
);
sub init {
my ($class, $c, $conf) = @_;
$conf = do {
$conf ||= {};
for my $key (qw/secure_headers json_escape_filter json/) {
if (exists $conf->{$key} && !defined $conf->{$key}) {
$conf->{$key} = undef;
}
else {
$conf->{$key} = {
%{ $DEFAULT_CONFIG{$key} },
%{ $conf->{$key} || {} },
}
}
}
+{ %DEFAULT_CONFIG, %{$conf} };
};
my $name = $conf->{name};
unless ($c->can($name)) {
my $render_json = _generate_render_json($conf);
Amon2::Util::add_method($c, $name, $render_json)
}
}
sub _generate_render_json {
my $conf = shift;
my $encoder = _generate_json_encoder($conf);
my $validator = _generate_req_validator($conf);
my $secure_headers;
if ($conf->{secure_headers}) {
$secure_headers = HTTP::SecureHeaders->new(%{ $conf->{secure_headers} });
}
return sub {
my ($c, $data, $spec, $status) = @_;
$status //= 200;
if (my $error = $validator->($c)) {
return $error;
}
my $output = $encoder->($data, $spec);
my $res = do {
my $res = $c->create_response($status);
my $encoding = $c->encoding();
$encoding = lc($encoding->mime_name) if ref $encoding;
$res->content_type("application/json; charset=$encoding");
$res->content_length(length($output));
$res->body($output);
if ($secure_headers) {
$secure_headers->apply($res->headers);
}
# X-API-Status
if (my $status_code_field = $conf->{status_code_field}) {
if (exists $data->{$status_code_field}) {
$res->header('X-API-Status' => $data->{$status_code_field})
}
}
$res
};
return $res;
}
}
sub _generate_json_encoder {
my $conf = shift;
my $json = Cpanel::JSON::XS->new;
if (my $json_args = $conf->{json}) {
for my $key (keys %{$json_args}) {
$json->$key($json_args->{$key})
}
}
my $escape_filter = $conf->{json_escape_filter} || {};
my $escape_target = '';
for my $key (keys %{$escape_filter}) {
if ($escape_filter->{$key}) {
$escape_target .= $key
}
}
return sub {
my ($data, $spec) = @_;
if (my $unbless_object = $conf->{unbless_object}) {
if (blessed($data)) {
$data = $unbless_object->($data, $spec);
}
}
my $output = $json->encode($data, $spec);
if ($escape_target && $escape_filter) {
$output =~ s!([$escape_target])!$escape_filter->{$1}!g;
}
return $output;
}
}
sub _generate_req_validator {
my $conf = shift;
return sub {
my ($c) = @_;
# defense from JSON hijacking
if ($conf->{defence_json_hijacking_for_legacy_browser}) {
my $user_agent = $c->req->user_agent || '';
if (
(!$c->req->header('X-Requested-With')) &&
$user_agent =~ /android/i &&
defined $c->req->header('Cookie') &&
($c->req->method||'GET') eq 'GET'
) {
return _error_response($c);
}
}
}
}
sub _error_response {
my $c = shift;
my $res = $c->create_response(403);
$res->content_type('text/plain');
$res->content("invalid JSON request");
$res->content_length(length $res->content);
return $res;
}
1;
__END__
=encoding utf-8
=head1 NAME
Amon2::Plugin::Web::CpanelJSON - Cpanel::JSON::XS plugin
=head1 SYNOPSIS
use Amon2::Lite;
use Cpanel::JSON::XS::Type;
use HTTP::Status qw(:constants);
__PACKAGE__->load_plugins(qw/Web::CpanelJSON/);
use constant HelloWorld => {
message => JSON_TYPE_STRING,
};
get '/' => sub {
my $c = shift;
return $c->render_json(+{ message => 'HELLO!' }, HelloWorld, HTTP_OK);
};
__PACKAGE__->to_app();
=head1 DESCRIPTION
This is a JSON plugin for Amon2.
The differences from Amon2::Plugin::Web::JSON are as follows.
* Cpanel::JSON::XS::Type is available
* HTTP status code can be specified
* Flexible Configurations
=head1 METHODS
=over 4
=item C<< $c->render_json($data, $json_spec, $status=200); >>
Generate JSON C<< $data >> and C<< $json_spec >> and returns instance of L<Plack::Response>.
C<< $json_spec >> is a structure for JSON encoding defined in L<Cpanel::JSON::XS::Type>.
=back
=head1 CONFIGURATION
=over 4
=item json
Parameters of L<Cpanel::JSON::XS>. Default is as follows:
ascii => !!1,
Any parameters can be set:
__PACKAGE__->load_plugins(
'Web::CpanelJSON' => {
json => {
ascii => 0,
utf8 => 1,
canonical => 1,
}
}
);
=item secure_headers
Parameters of L<HTTP::SecureHeaders>. Default is as follows:
content_security_policy => "default-src 'none'",
strict_transport_security => 'max-age=631138519',
x_content_type_options => 'nosniff',
x_download_options => undef,
x_frame_options => 'DENY',
x_permitted_cross_domain_policies => 'none',
x_xss_protection => '1; mode=block',
referrer_policy => 'no-referrer',
=item json_escape_filter
Escapes JSON to prevent XSS. Default is as follows:
'+' => '\\u002b',
'<' => '\\u003c',
'>' => '\\u003e',
=item name
Name of method. Default: 'render_json'
=item unbless_object
Default: undef
This option is preprocessing coderef encoding an blessed object in JSON.
For example, the code using L<Object::UnblessWithJSONSpec> is as follows:
use Object::UnblessWithJSONSpec ();
__PACKAGE__->load_plugins(
'Web::CpanelJSON' => {
unbless_object => \&Object::UnblessWithJSONSpec::unbless_with_json_spec,
}
);
...
package Some::Object {
use Mouse;
has message => (
is => 'ro',
);
}
my $object = Some::Object->new(message => 'HELLO');
$c->render_json($object, { message => JSON_TYPE_STRING })
# => {"message":"HELLO"}
=item status_code_field
Default: undef
It specify the field name of JSON to be embedded in the C<< X-API-Status >> header.
Default is C<< undef >>. If you set the C<< undef >> to disable this C<< X-API-Status >> header.
__PACKAGE__->load_plugins(
'Web::CpanelJSON' => { status_code_field => 'status' }
);
...
$c->render_json({ status => 200, message => 'ok' })
# send response header 'X-API-Status: 200'
In general JSON API error code embed in a JSON by JSON API Response body.
But can not be logging the error code of JSON for the access log of a general Web Servers.
You can possible by using the C<< X-API-Status >> header.
=item defence_json_hijacking_for_legacy_browser
Default: false
=back
=head1 LICENSE
Copyright (C) kfly8.
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
kfly8 E<lt>kfly@cpan.orgE<gt>
=cut