commit ffc30d4eb425090484310249ea19602cfa8829c5
Author: Robert Rothenberg <perl@rhizomnic.com>
Date:   Fri Jul 3 11:29:11 2026 +0100

    Add support for OAuth2 state to fix CVE-2026-12740
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    
    Signed-off-by: Robert Rothenberg <rrwo@cpansec.org>

diff --git a/Makefile.PL b/Makefile.PL
index 295013e..544591a 100644
--- a/Makefile.PL
+++ b/Makefile.PL
@@ -10,7 +10,8 @@ readme_from    'lib/Plack/Middleware/OAuth.pm';
 readme_markdown_from 'lib/Plack/Middleware/OAuth.pm';
 
 requires       
-    'Plack::Middleware::Session' => 0,
+    'Plack::Middleware::Session' => 0.35,
+    'Crypt::SysRandom' => 0,
     'File::Spec'  => 0,
     'DateTime' => 0,
     'Digest::MD5' => 0,
diff --git a/lib/Plack/Middleware/OAuth/Handler/AccessTokenV2.pm b/lib/Plack/Middleware/OAuth/Handler/AccessTokenV2.pm
index 1aedbc9..0b6c4c7 100644
--- a/lib/Plack/Middleware/OAuth/Handler/AccessTokenV2.pm
+++ b/lib/Plack/Middleware/OAuth/Handler/AccessTokenV2.pm
@@ -1,9 +1,11 @@
+use utf8;
 package Plack::Middleware::OAuth::Handler::AccessTokenV2;
 use parent qw(Plack::Middleware::OAuth::Handler);
 use URI;
 use URI::Query;
 use LWP::UserAgent;
 use Plack::Middleware::OAuth::AccessToken;
+use Plack::Session;
 use Try::Tiny;
 use JSON::Any;
 use warnings;
@@ -46,9 +48,9 @@ sub get_access_token {
     my %params;
 
     # we are pretty sure, the response is json format
-	if(    $content_type =~ m{json} 
-        || $content_type =~ m{javascript} 
-        || $response_content =~ m{^\{.*?\}\s*$}s ) 
+	if(    $content_type =~ m{json}
+        || $content_type =~ m{javascript}
+        || $response_content =~ m{^\{.*?\}\s*$}s )
     {
         try {
             %params = %{ JSON::Any->new->decode( $response_content ) }; # should be hashref.
@@ -56,7 +58,7 @@ sub get_access_token {
             # XXX: show exception page for this.
             die "Can not decode json: " . $_;
         };
-	} 
+	}
     else {
         try {
             my $qq = URI::Query->new( $ua_response->content );
@@ -79,6 +81,21 @@ sub get_access_token {
 
 sub run {
     my $self = $_[0];
+    my $provider = $self->provider;
+    my $env = $self->env;
+
+    # CSRF defense (RFC 6749 §10.12): the callback must carry the
+    # same `state` we issued in RequestTokenV2 and stored in the
+    # session. Mismatch / absence => login-CSRF attempt; refuse.
+    my $session = Plack::Session->new($env);
+    my $expected_state = $session->get("oauth.${provider}.state");
+    my $req_state      = $self->param('state');
+    if ( !defined($expected_state) || !defined($req_state)
+         || $expected_state ne $req_state ) {
+        return $self->render('OAuth state mismatch (login-CSRF defense).');
+    }
+    $session->remove("oauth.${provider}.state");
+
 	my $code = $self->param('code');
 
 	# https://graph.facebook.com/oauth/access_token?
@@ -97,8 +114,6 @@ sub run {
     }
 
 	# register oauth args to session
-    my $env = $self->env;
-    my $provider = $self->provider;
 
     $token->register_session($env);
 
diff --git a/lib/Plack/Middleware/OAuth/Handler/RequestTokenV2.pm b/lib/Plack/Middleware/OAuth/Handler/RequestTokenV2.pm
index cdc5263..3907936 100644
--- a/lib/Plack/Middleware/OAuth/Handler/RequestTokenV2.pm
+++ b/lib/Plack/Middleware/OAuth/Handler/RequestTokenV2.pm
@@ -2,6 +2,8 @@ package Plack::Middleware::OAuth::Handler::RequestTokenV2;
 use warnings;
 use strict;
 use parent qw(Plack::Middleware::OAuth::Handler);
+use Crypt::SysRandom qw(random_bytes);
+use Plack::Session;
 
 sub default_callback {
     my $self = shift;
@@ -17,6 +19,16 @@ sub default_callback {
 sub run {
     my $self = shift;
     my $config = $self->config;
+    my $provider = $self->provider;
+    my $env = $self->env;
+
+    # CSRF defense (RFC 6749 §10.12): generate a CSPRNG-backed state
+    # token, store it in the session keyed by provider name, send it
+    # to the upstream so the callback can verify the response was
+    # initiated by THIS browser session.
+    my $state = unpack("H*", random_bytes(16));
+    my $session = Plack::Session->new($env);
+    $session->set("oauth.${provider}.state", $state);
 
 	# "https://www.facebook.com/dialog/oauth?client_id=YOUR_APP_ID&redirect_uri=YOUR_URL";
 	my $uri = URI->new( $config->{authorize_url} );
@@ -25,6 +37,7 @@ sub run {
 		redirect_uri  => $config->{redirect_uri}  || $self->default_callback,
 		response_type => $config->{response_type} || 'code',
 		scope         => $config->{scope},
+		state         => $state,
     );
 	$uri->query_form( %query );
 	return $self->redirect( $uri );
