微信小程序信息安全

master
lion 3 weeks ago
parent a20be1201e
commit 9bd60445fd

@ -54,3 +54,7 @@ PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# Match WeChat mini-program message push settings
WECHAT_CONTENT_SECURITY_TOKEN=
WECHAT_CONTENT_SECURITY_AES_KEY=

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Mobile;
use App\Helpers\ResponseCode;
use App\Services\ContentSecurity\AvatarCheck;
class ContentSecurityController extends CommonController
{
public function avatarStatus(AvatarCheck $checks) {
try { return $this->success($checks->result($this->getUserId(), (int) request('check_id'))); }
catch (\InvalidArgumentException $e) { return $this->fail([ResponseCode::ERROR_BUSINESS, $e->getMessage()]); }
}
public function callback(AvatarCheck $checks) {
abort_unless(config('content-security.token') && config('content-security.aes_key'), 503);
$app = miniProgramApp(['token' => config('content-security.token'), 'aes_key' => config('content-security.aes_key'), 'response_type' => 'array']);
// Explicitly bind the current request, including JSON/encrypted event bodies.
$app->rebind('request', request());
$app->server->forceValidate();
$app->server->push(function ($message) use ($checks) { $checks->receive($message); });
try {
$app->server->validate();
if (request()->isMethod('get')) return response((string) request('echostr', ''));
return $app->server->serve();
}
catch (\EasyWeChat\Kernel\Exceptions\BadRequestException $e) { abort(400, 'Invalid WeChat callback'); }
}
}

@ -32,6 +32,11 @@ class UploadController extends CommonController
if ($fileSize >= 50 * 1024 *1024) {
return $this->fail([ResponseCode::ERROR_BUSINESS, '文件必须小于50M']);
}
if ($request->input('purpose') === 'avatar') {
if ($fileSize > 1024 * 1024 || !in_array($file->getMimeType(), ['image/jpeg', 'image/png'], true)) {
return $this->fail([ResponseCode::ERROR_BUSINESS, '头像请使用1MB以内的JPG或PNG图片']);
}
}
$originalName = $request->input('original_name');
if (!is_string($originalName) || trim($originalName) === '') $originalName = $file->getClientOriginalName();
//过滤文件后缀
@ -60,6 +65,10 @@ class UploadController extends CommonController
];
$id = Upload::insertGetId($list);
$uploadFile = Upload::find($id);
if ($request->input('purpose') === 'avatar') {
try { return $this->success(app(\App\Services\ContentSecurity\AvatarCheck::class)->submit($this->getUser(), $uploadFile)); }
catch (\InvalidArgumentException $e) { return $this->fail([ResponseCode::ERROR_BUSINESS, $e->getMessage()]); }
}
return $this->success($uploadFile);
} else {
return $this->fail([ResponseCode::ERROR_BUSINESS, '文件格式错误']);

@ -168,6 +168,10 @@ class UserController extends CommonController
{
$all = \request()->except(['id', 'mobile', 'openid', 'survey_id']);
$model = User::find($this->getUserId());
if (array_key_exists('headimgurl', $all) && $all['headimgurl'] !== $model->headimgurl) {
try { app(\App\Services\ContentSecurity\AvatarCheck::class)->assertApproved($model->id, (string) $all['headimgurl']); }
catch (\InvalidArgumentException $e) { return $this->fail([ResponseCode::ERROR_BUSINESS, $e->getMessage()]); }
}
$oldCompanyName = $model->company_name;
if (isset($all['password'])) {
// 判断旧密码是否正确

@ -0,0 +1,66 @@
<?php
namespace App\Services\ContentSecurity;
use App\Models\Upload;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
class AvatarCheck
{
public function submit($user, Upload $upload): array
{
if (!$user->openid) throw new InvalidArgumentException('请重新进入小程序后再试');
$id = DB::table('content_security_checks')->insertGetId([
'user_id' => $user->id, 'upload_id' => $upload->id, 'url' => $upload->url,
'status' => 'pending', 'created_at' => now(), 'updated_at' => now(),
]);
try {
// The SDK convenience method uses v1; explicitly send all v2 parameters.
$result = miniProgramApp()->content_security->httpPostJson('wxa/media_check_async', [
'media_url' => $upload->url, 'media_type' => 2, 'version' => 2,
'scene' => 1, 'openid' => $user->openid,
]);
if ((int) ($result['errcode'] ?? -1) !== 0 || empty($result['trace_id'])) throw new \RuntimeException('WeChat media check submission failed');
DB::table('content_security_checks')->where('id', $id)->update(['trace_id' => $result['trace_id'], 'updated_at' => now()]);
} catch (\Throwable $e) {
DB::table('content_security_checks')->where('id', $id)->update(['status' => 'error', 'updated_at' => now()]);
Log::warning('Avatar check submission failed', ['check_id' => $id, 'exception' => get_class($e)]);
throw new InvalidArgumentException('内容检测暂不可用,请稍后重新上传');
}
return ['check_id' => $id, 'status' => 'pending'];
}
public function result(int $userId, int $id): array
{
$row = DB::table('content_security_checks')->where('id', $id)->where('user_id', $userId)->first();
if (!$row) throw new InvalidArgumentException('检测记录不存在');
$status = $row->status;
if ($status === 'pending' && strtotime($row->created_at) < time() - 600) $status = 'error';
return ['check_id' => $row->id, 'status' => $status, 'url' => $status === 'pass' ? $row->url : null];
}
public function receive(array $message): void
{
if (($message['Event'] ?? '') !== 'wxa_media_check') return;
$trace = $message['trace_id'] ?? '';
if (!$trace) return;
if (!DB::table('content_security_checks')->where('trace_id', $trace)->exists()) {
// A callback may arrive before the submission response is stored; request a retry.
throw new \RuntimeException('Unknown media check trace');
}
$suggest = $message['result']['suggest'] ?? '';
$error = (int) ($message['errcode'] ?? -1);
$status = $error !== 0 ? 'error' : ($suggest === 'pass' ? 'pass' : 'reject');
// Only a signed callback can reach here. Duplicates cannot overwrite a final decision.
DB::table('content_security_checks')->where('trace_id', $trace)->where('status', 'pending')
->update(['status' => $status, 'suggest' => $suggest, 'error_code' => $error, 'updated_at' => now()]);
}
public function assertApproved(int $userId, string $url): void
{
$approved = DB::table('content_security_checks')->where('user_id', $userId)->where('url', $url)
->where('status', 'pass')->exists();
if (!$approved) throw new InvalidArgumentException('头像尚未通过内容检测,请重新上传并等待检测通过');
}
}

@ -0,0 +1,5 @@
<?php
return [
'token' => env('WECHAT_CONTENT_SECURITY_TOKEN'),
'aes_key' => env('WECHAT_CONTENT_SECURITY_AES_KEY'),
];

@ -0,0 +1,12 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up() { Schema::create('content_security_checks', function (Blueprint $t) {
$t->id(); $t->unsignedBigInteger('user_id')->index(); $t->unsignedBigInteger('upload_id')->index();
$t->string('trace_id')->nullable()->unique(); $t->string('status')->default('pending');
$t->text('url'); $t->string('suggest')->nullable(); $t->integer('error_code')->nullable(); $t->timestamps();
}); }
public function down() { Schema::dropIfExists('content_security_checks'); }
};

@ -18,6 +18,8 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
Route::match(['get', 'post'], 'wechat/content-security/callback', [\App\Http\Controllers\Mobile\ContentSecurityController::class, 'callback']);
Route::get('test', [\App\Http\Controllers\Admin\OtherController::class, "test"]);
// 车辆进出场
@ -423,6 +425,7 @@ Route::group(["namespace" => "Mobile", "prefix" => "mobile"], function () {
// 公司详情
Route::get('other/company-detail', [\App\Http\Controllers\Mobile\OtherController::class, "companyDetail"]);
// 其他
Route::get('content-security/avatar-status', [\App\Http\Controllers\Mobile\ContentSecurityController::class, 'avatarStatus']);
Route::post('upload-file', [\App\Http\Controllers\Mobile\UploadController::class, "uploadFile"]);
// 用户信息
Route::get('user/account-login', [\App\Http\Controllers\Mobile\UserController::class, "accountLogin"]);

@ -0,0 +1,92 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Services\ContentSecurity\AvatarCheck;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
class AvatarContentSecurityTest extends TestCase
{
protected function setUp(): void {
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:']);
DB::purge('sqlite');
(require database_path('migrations/2026_10_23_100000_create_content_security_checks.php'))->up();
}
protected function tearDown(): void { DB::disconnect('sqlite'); parent::tearDown(); }
private function pending() {
return DB::table('content_security_checks')->insertGetId(['user_id' => 1, 'upload_id' => 7, 'trace_id' => 'trace-1', 'url' => 'https://example.test/avatar.jpg', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
}
public function test_pending_reject_error_and_foreign_images_cannot_be_saved() {
$this->pending(); $service = new AvatarCheck;
foreach (['pending', 'reject', 'error', 'pass'] as $status) {
DB::table('content_security_checks')->update(['status' => $status]);
foreach ([1, 2] as $user) {
try { $service->assertApproved($user, 'https://example.test/avatar.jpg'); $allowed = true; }
catch (\InvalidArgumentException $e) { $allowed = false; }
$this->assertSame($status === 'pass' && $user === 1, $allowed);
}
}
$this->expectException(\InvalidArgumentException::class);
$service->assertApproved(1, 'https://example.test/arbitrary.jpg');
}
public function test_only_successful_pass_callback_approves_and_duplicates_are_ignored() {
$id = $this->pending(); $service = new AvatarCheck;
$this->assertNull($service->result(1, $id)['url']);
$service->receive(['Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'pass']]);
$this->assertSame('pass', $service->result(1, $id)['status']);
$service->receive(['Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'risky']]);
$this->assertSame('pass', $service->result(1, $id)['status']);
$this->expectException(\InvalidArgumentException::class); $service->result(2, $id);
}
public function test_review_and_missing_results_are_not_pass() {
foreach ([['errcode'=>0,'result'=>['suggest'=>'review']], ['errcode'=>0], ['errcode'=>-1,'result'=>['suggest'=>'pass']]] as $data) {
DB::table('content_security_checks')->delete(); $id=$this->pending();
(new AvatarCheck)->receive(array_merge(['Event'=>'wxa_media_check','trace_id'=>'trace-1'], $data));
$this->assertNotSame('pass', (new AvatarCheck)->result(1, $id)['status']);
}
}
public function test_callback_rejects_unsigned_requests() {
config(['content-security.token'=>'test-token','content-security.aes_key'=>str_repeat('a',43)]);
$this->postJson('/api/wechat/content-security/callback', ['Event'=>'wxa_media_check'])->assertStatus(400);
}
public function test_signed_callback_updates_check_and_returns_success() {
$id=$this->pending();
config(['content-security.token'=>'test-token','content-security.aes_key'=>str_repeat('a',43)]);
$timestamp=(string)time(); $nonce='test-nonce'; $parts=['test-token',$timestamp,$nonce]; sort($parts,SORT_STRING);
$url='/api/wechat/content-security/callback?timestamp='.$timestamp.'&nonce='.$nonce.'&signature='.sha1(implode('', $parts));
$this->postJson($url, ['MsgType'=>'event','Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'pass']])->assertOk();
$this->assertSame('pass', (new AvatarCheck)->result(1,$id)['status']);
}
public function test_encrypted_callback_is_decrypted_and_checked() {
$id=$this->pending(); $token='test-token'; $key=str_repeat('a',43); $appid='wx-test';
config(['content-security.token'=>$token,'content-security.aes_key'=>$key,'app.applet_appid'=>$appid]);
$timestamp=time(); $nonce='encrypted-nonce'; $parts=[$token,(string)$timestamp,$nonce]; sort($parts,SORT_STRING);
$message=json_encode(['MsgType'=>'event','Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'pass']]);
$encrypted=(new \EasyWeChat\MiniProgram\Encryptor($appid,$token,$key))->encrypt($message,$nonce,$timestamp);
$envelope=\EasyWeChat\Kernel\Support\XML::parse($encrypted);
$url='/api/wechat/content-security/callback?encrypt_type=aes&timestamp='.$timestamp.'&nonce='.$nonce.'&signature='.sha1(implode('',$parts)).'&msg_signature='.$envelope['MsgSignature'];
$this->call('POST',$url,[],[],[],['CONTENT_TYPE'=>'text/xml'],$encrypted)->assertOk();
$this->assertSame('pass',(new AvatarCheck)->result(1,$id)['status']);
}
public function test_update_endpoint_refuses_unapproved_avatar_without_changing_user() {
$this->withoutMiddleware();
Schema::create('users',function(Blueprint $t){$t->id();$t->string('headimgurl')->nullable();$t->timestamps();$t->softDeletes();});
DB::table('users')->insert(['id'=>1,'headimgurl'=>'old']);
\Illuminate\Support\Facades\Auth::guard('mobile')->setUser(\App\Models\User::find(1));
$this->postJson('/api/mobile/user/update-user',['headimgurl'=>'https://example.test/unreviewed.jpg'])->assertOk()->assertJsonStructure(['errcode','errmsg']);
$this->assertSame('old',DB::table('users')->where('id',1)->value('headimgurl'));
\Illuminate\Support\Facades\Auth::guard('mobile')->forgetUser();
}
public function test_callback_url_verification_echoes_only_with_valid_signature() {
config(['content-security.token'=>'test-token','content-security.aes_key'=>str_repeat('a',43)]);
$timestamp=(string)time(); $nonce='verify'; $parts=['test-token',$timestamp,$nonce]; sort($parts,SORT_STRING);
$url='/api/wechat/content-security/callback?timestamp='.$timestamp.'&nonce='.$nonce.'&echostr=hello&signature=';
$this->get($url.'invalid')->assertStatus(400);
$this->get($url.sha1(implode('',$parts)))->assertOk()->assertSeeText('hello');
}
}
Loading…
Cancel
Save