Skip to content

Commit

Permalink
Closes #108: Anti-CSRF middleware
Browse files Browse the repository at this point in the history
Add to middleware in app's `src/main.zig`:

```zig
pub const jetzig_options = struct {
    pub const middleware: []const type = &.{
        jetzig.middleware.AntiCsrfMiddleware,
    };
};
```

CSRF token available in Zmpl templates:

```
{{context.authenticityToken()}}
```
or render a hidden form element:
```
{{context.authenticityFormElement()}}
```

The following HTML requests are rejected (403 Forbidden) if the
submitted query param does not match the value stored in the encrypted
session (added automatically when the token is generated for a template
value):

* POST
* PUT
* PATCH
* DELETE

JSON requests are not impacted - users should either disable JSON
endpoints or implement a different authentication method to protect
them.
  • Loading branch information
bobf committed Nov 23, 2024
1 parent d887cd5 commit f817a13
Show file tree
Hide file tree
Showing 28 changed files with 694 additions and 138 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
- name: Run App Tests
run: |
cd demo
zig build jetzig:test
zig build -Denvironment=test jetzig:test
- name: Build artifacts
if: ${{ matrix.os == 'ubuntu-latest' }}
Expand Down
21 changes: 18 additions & 3 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ pub fn build(b: *std.Build) !void {
main_tests.root_module.addImport("smtp", smtp_client_dep.module("smtp_client"));
const test_build_options = b.addOptions();
test_build_options.addOption(Environment, "environment", .testing);
test_build_options.addOption(bool, "build_static", true);
const run_main_tests = b.addRunArtifact(main_tests);
main_tests.root_module.addOptions("build_options", test_build_options);

Expand Down Expand Up @@ -137,6 +138,11 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
"environment",
"Jetzig server environment.",
) orelse .development;
const build_static = b.option(
bool,
"build_static",
"Pre-render static routes. [default: false in development, true in testing/production]",
) orelse (environment != .development);

const jetzig_dep = b.dependency(
"jetzig",
Expand Down Expand Up @@ -164,6 +170,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn

const build_options = b.addOptions();
build_options.addOption(Environment, "environment", environment);
build_options.addOption(bool, "build_static", build_static);
jetzig_module.addOptions("build_options", build_options);

exe.root_module.addImport("jetzig", jetzig_module);
Expand Down Expand Up @@ -253,15 +260,23 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
exe_static_routes.root_module.addImport("zmpl", zmpl_module);

const markdown_fragments_write_files = b.addWriteFiles();
const path = markdown_fragments_write_files.add("markdown_fragments.zig", try generateMarkdownFragments(b));
const path = markdown_fragments_write_files.add(
"markdown_fragments.zig",
try generateMarkdownFragments(b),
);
const markdown_fragments_module = b.createModule(.{ .root_source_file = path });
exe_static_routes.root_module.addImport("markdown_fragments", markdown_fragments_module);

const run_static_routes_cmd = b.addRunArtifact(exe_static_routes);
const static_outputs_path = run_static_routes_cmd.addOutputFileArg("static.zig");
const static_module = b.createModule(.{ .root_source_file = static_outputs_path });
exe.root_module.addImport("static", static_module);
const static_module = if (build_static)
b.createModule(.{ .root_source_file = static_outputs_path })
else
b.createModule(.{
.root_source_file = jetzig_dep.builder.path("src/jetzig/development_static.zig"),
});

exe.root_module.addImport("static", static_module);
run_static_routes_cmd.expectExitCode(0);

const run_tests_file_cmd = b.addRunArtifact(exe_routes_file);
Expand Down
4 changes: 2 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
.hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163",
},
.zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/25b91d030b992631d319adde1cf01baecd9f3934.tar.gz",
.hash = "12208dd5a4bf0c6c7efc4e9f37a5d8ed80d6004d5680176d1fc2114bfa593e927baf",
.url = "https://github.com/jetzig-framework/zmpl/archive/af75c8b842c3957eb97b4fc4bc49c7b2243968fa.tar.gz",
.hash = "1220ecac93d295dafd2f034a86f0979f6108d40e5ea1a39e3a2b9977c35147cac684",
},
.jetkv = .{
.url = "https://github.com/jetzig-framework/jetkv/archive/2b1130a48979ea2871c8cf6ca89c38b1e7062839.tar.gz",
Expand Down
77 changes: 77 additions & 0 deletions demo/src/app/views/anti_csrf.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const std = @import("std");
const jetzig = @import("jetzig");

pub const layout = "application";

pub const actions = .{
.before = .{jetzig.middleware.AntiCsrfMiddleware},
};

pub fn post(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);

const Params = struct { spam: []const u8 };
const params = try request.expectParams(Params) orelse {
return request.fail(.unprocessable_entity);
};

try root.put("spam", params.spam);

return request.render(.created);
}

pub fn index(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}

test "post with missing token" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();

const response = try app.request(.POST, "/anti_csrf", .{});
try response.expectStatus(.forbidden);
}

test "post with invalid token" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();

const response = try app.request(.POST, "/anti_csrf", .{});
try response.expectStatus(.forbidden);
}

test "post with valid token but missing expected params" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();

_ = try app.request(.GET, "/anti_csrf", .{});
const token = app.session.getT(.string, jetzig.authenticity_token_name).?;
const response = try app.request(
.POST,
"/anti_csrf",
.{ .params = .{ ._jetzig_authenticity_token = token } },
);
try response.expectStatus(.unprocessable_entity);
}

test "post with valid token and expected params" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();

_ = try app.request(.GET, "/anti_csrf", .{});
const token = app.session.getT(.string, jetzig.authenticity_token_name).?;
const response = try app.request(
.POST,
"/anti_csrf",
.{ .params = .{ ._jetzig_authenticity_token = token, .spam = "Spam" } },
);
try response.expectStatus(.created);
}

test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();

const response = try app.request(.GET, "/anti_csrf", .{});
try response.expectStatus(.ok);
}
8 changes: 8 additions & 0 deletions demo/src/app/views/anti_csrf/index.zmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<form action="/anti_csrf" method="POST">
{{context.authenticityFormElement()}}

<label>Enter spam here:</label>
<input type="text" name="spam" />

<input type="submit" value="Submit Spam" />
</form>
5 changes: 5 additions & 0 deletions demo/src/app/views/anti_csrf/post.zmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<h1>Spam Submitted Successfully</h1>

<h2>Spam:</h2>

<div>{{$.spam}}</div>
17 changes: 11 additions & 6 deletions demo/src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ pub const jetzig_options = struct {
/// Middleware chain. Add any custom middleware here, or use middleware provided in
/// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`).
pub const middleware: []const type = &.{
// jetzig.middleware.AuthMiddleware,
// jetzig.middleware.AntiCsrfMiddleware,
// jetzig.middleware.HtmxMiddleware,
// jetzig.middleware.CompressionMiddleware,
// @import("app/middleware/DemoMiddleware.zig"),
Expand Down Expand Up @@ -79,13 +81,16 @@ pub const jetzig_options = struct {
pub const Schema = @import("Schema");

/// HTTP cookie configuration
pub const cookies: jetzig.http.Cookies.CookieOptions = .{
.domain = switch (jetzig.environment) {
.development => "localhost",
.testing => "localhost",
.production => "www.example.com",
pub const cookies: jetzig.http.Cookies.CookieOptions = switch (jetzig.environment) {
.development, .testing => .{
.domain = "localhost",
.path = "/",
},
.production => .{
.same_site = true,
.secure = true,
.http_only = true,
},
.path = "/",
};

/// Key-value store options. Set backend to `.file` to use a file-based store.
Expand Down
4 changes: 3 additions & 1 deletion src/Routes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
\\ .static = {4s},
\\ .uri_path = "{5s}",
\\ .template = "{6s}",
\\ .before_callbacks = jetzig.callbacks.beforeCallbacks(@import("{7s}")),
\\ .after_callbacks = jetzig.callbacks.afterCallbacks(@import("{7s}")),
\\ .layout = if (@hasDecl(@import("{7s}"), "layout")) @import("{7s}").layout else null,
\\ .json_params = &[_][]const u8 {{ {8s} }},
\\ .formats = if (@hasDecl(@import("{7s}"), "formats")) @import("{7s}").formats else null,
Expand Down Expand Up @@ -389,7 +391,7 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout

for (capture.args, 0..) |arg, arg_index| {
if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) {
capture.static = true;
capture.static = jetzig.build_options.build_static;
capture.legacy = arg_index + 1 < capture.args.len;
try static_routes.append(capture.*);
} else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/routes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub fn main() !void {

log("Jetzig Routes:", .{});

const environment = jetzig.Environment.init(allocator, .{ .silent = true });
const environment = try jetzig.Environment.init(allocator, .{ .silent = true });
const initHook: ?*const fn (*jetzig.App) anyerror!void = if (@hasDecl(app, "init")) app.init else null;

inline for (routes.routes) |route| max_uri_path_len = @max(route.uri_path.len + 5, max_uri_path_len);
Expand Down Expand Up @@ -44,7 +44,7 @@ pub fn main() !void {
}

var jetzig_app = jetzig.App{
.environment = environment,
.env = environment,
.allocator = allocator,
.custom_routes = std.ArrayList(jetzig.views.Route).init(allocator),
.initHook = initHook,
Expand Down
11 changes: 8 additions & 3 deletions src/compile_static_routes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ fn renderMarkdown(

if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
view.data.content = .{ .data = content };
return try layout.render(view.data);
return try layout.render(view.data, jetzig.TemplateContext, .{}, .{});
} else {
std.debug.print("Unknown layout: {s}\n", .{layout_name});
return content;
Expand All @@ -170,13 +170,18 @@ fn renderZmplTemplate(
defer allocator.free(prefixed_name);

if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
return try template.renderWithOptions(view.data, .{ .layout = layout });
return try template.render(
view.data,
jetzig.TemplateContext,
.{},
.{ .layout = layout },
);
} else {
std.debug.print("Unknown layout: {s}\n", .{layout_name});
return try allocator.dupe(u8, "");
}
} else {
return try template.render(view.data);
return try template.render(view.data, jetzig.TemplateContext, .{}, .{});
}
} else return null;
}
Expand Down
10 changes: 9 additions & 1 deletion src/jetzig.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@ pub const database = @import("jetzig/database.zig");
pub const testing = @import("jetzig/testing.zig");
pub const config = @import("jetzig/config.zig");
pub const auth = @import("jetzig/auth.zig");
pub const callbacks = @import("jetzig/callbacks.zig");
pub const TemplateContext = @import("jetzig/TemplateContext.zig");

pub const DateTime = jetcommon.types.DateTime;
pub const Time = jetcommon.types.Time;
pub const Date = jetcommon.types.Date;

pub const authenticity_token_name = config.get([]const u8, "authenticity_token_name");

pub const build_options = @import("build_options");
pub const environment = std.enums.nameCast(Environment.EnvironmentName, build_options.environment);

Expand All @@ -46,6 +50,9 @@ pub const Request = http.Request;
/// requests.
pub const StaticRequest = http.StaticRequest;

/// An HTTP response generated during request processing.
pub const Response = http.Response;

/// Generic, JSON-compatible data type. Provides `Value` which in turn provides `Object`,
/// `Array`, `String`, `Integer`, `Float`, `Boolean`, and `NullType`.
pub const Data = data.Data;
Expand Down Expand Up @@ -78,7 +85,8 @@ pub const Logger = loggers.Logger;

pub const root = @import("root");
pub const Global = if (@hasDecl(root, "Global")) root.Global else DefaultGlobal;
pub const DefaultGlobal = struct { __jetzig_default: bool };
pub const DefaultGlobal = struct { comptime __jetzig_default: bool = true };
pub const default_global = DefaultGlobal{};

pub const initHook: ?*const fn (*App) anyerror!void = if (@hasDecl(root, "init")) root.init else null;

Expand Down
6 changes: 4 additions & 2 deletions src/jetzig/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ pub fn deinit(self: *const App) void {
@constCast(self).custom_routes.deinit();
}

// Not used yet, but allows us to add new options to `start()` without breaking
// backward-compatibility.
/// Specify a global value accessible as `request.server.global`.
/// Must specify type by defining `pub const Global` in your app's `src/main.zig`.
const AppOptions = struct {
global: *anyopaque = undefined,
};
Expand Down Expand Up @@ -228,6 +228,8 @@ pub fn createRoutes(
.template = const_route.template,
.json_params = const_route.json_params,
.formats = const_route.formats,
.before_callbacks = const_route.before_callbacks,
.after_callbacks = const_route.after_callbacks,
};

try var_route.initParams(allocator);
Expand Down
25 changes: 25 additions & 0 deletions src/jetzig/TemplateContext.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const std = @import("std");

pub const http = @import("http.zig");
pub const config = @import("config.zig");

/// Context available in every Zmpl template as `context`.
pub const TemplateContext = @This();

request: ?*http.Request = null,

pub fn authenticityToken(self: TemplateContext) !?[]const u8 {
return if (self.request) |request|
try request.authenticityToken()
else
null;
}

pub fn authenticityFormElement(self: TemplateContext) !?[]const u8 {
return if (self.request) |request| blk: {
const token = try request.authenticityToken();
break :blk try std.fmt.allocPrint(request.allocator,
\\<input type="hidden" name="{s}" value="{s}" />
, .{ config.get([]const u8, "authenticity_token_name"), token });
} else null;
}
Loading

0 comments on commit f817a13

Please sign in to comment.