AWS CDK Link Shortener Teil 3: Advanced Features & Security
Implementation von Custom Domains, Bulk-Operationen, URL-Ablauf und umfassende Security-Maßnahmen. Production Security Incidents und wie wir Defense-in-Depth Schutz aufgebaut haben.
AWS CDK Link Shortener Teil 3: Advanced Features & Security#
Stell dir vor: Es ist die Woche vor Black Friday, und dein Marketing Team hat gerade 50.000 Product Links für ihre Kampagne hochgeladen. Alles sah super aus bis Montagmorgen, als deine Security Alerts losgingen. Jemand hatte deine Bulk Upload API entdeckt und nutzte sie, um Traffic zu... sagen wir mal "unpassenden Inhalten" zu leiten.
Dieser Vorfall hat mir beigebracht, dass der Bau eines Production Link Shorteners nicht nur um das Erstellen kurzer URLs geht—es geht darum, eine Festung zu bauen, die legitimen Scale handhaben kann, während sie die Bad Actors draußen hält. Nach dem Aufräumen dieses Chaos (und einigen unangenehmen Gesprächen mit unserem Compliance Team) haben wir unseren Service mit ordentlichen Security Layern neu aufgebaut.
In Teil 1 und Teil 2 haben wir das Foundation und die Core Redirect Funktionalität gebaut. Jetzt lass uns die Advanced Features und Security Measures hinzufügen, die ein Toy Project von einem Production Service unterscheiden.
Custom Short Domains: Mehr als nur Vanity URLs#
Bevor wir uns in Security stürzen, lass uns Custom Domains angehen. Dein Marketing Team wird irgendwann nach branded Short URLs wie acme.co/promo
statt yourdomain.com/abc123
fragen. So machst du es:
// lib/custom-domain-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * * targets from 'aws-cdk-lib/aws-route53-targets';
export class CustomDomainStack extends Stack {
public readonly customDomainName: apigateway.DomainName;
constructor(scope: Construct, id: string, props: StackProps & {
domainName: string;
hostedZoneId: string;
certificateArn: string; // Pre-created ACM certificate
restApi: apigateway.RestApi;
}) {
super(scope, id, props);
// Import existing hosted zone
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: props.hostedZoneId,
zoneName: props.domainName,
});
// Import existing certificate (must be in us-east-1 for API Gateway)
const certificate = acm.Certificate.fromCertificateArn(
this,
'Certificate',
props.certificateArn
);
// Create custom domain name for API Gateway
this.customDomainName = new apigateway.DomainName(this, 'CustomDomain', {
domainName: props.domainName,
certificate: certificate,
securityPolicy: apigateway.SecurityPolicy.TLS_1_2,
endpointType: apigateway.EndpointType.EDGE,
});
// Map the custom domain to our API
this.customDomainName.addBasePathMapping(props.restApi, {
basePath: '', // Root path
});
// Create Route53 alias record
new route53.ARecord(this, 'CustomDomainAlias', {
zone: hostedZone,
target: route53.RecordTarget.fromAlias(
new targets.ApiGatewayDomain(this.customDomainName)
),
});
}
}
Pro Tipp aus den Trenches: Erstell dein ACM Certificate immer in us-east-1
für API Gateway edge-optimized Endpoints, egal wo deine anderen Ressourcen leben. Ich hab zwei Stunden damit verbracht zu debuggen, warum mein Certificate "nicht existiert", bevor ich diese Anforderung gecheckt hab.
Bulk Operations: Scale Gracefully handhaben#
Marketing Teams lieben Bulk Operations. Hier ist eine Production-getestete Implementation, die deine Lambda Concurrency Limits nicht sprengt:
// lambda/bulk-create.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { nanoid } from 'nanoid';
const dynamodb = new DynamoDBClient({});
const sqs = new SQSClient({});
interface BulkCreateRequest {
urls: Array<{
originalUrl: string;
customSlug?: string;
expiresAt?: string;
tags?: string[];
}>;
userId: string;
}
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
try {
const request: BulkCreateRequest = JSON.parse(event.body || '{}');
// Validate batch size (learned this the hard way)
if (request.urls.length > 1000) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'Batch size cannot exceed 1000 URLs'
}),
};
}
// For large batches, use SQS for async processing
if (request.urls.length > 100) {
const jobId = nanoid();
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.BULK_PROCESSING_QUEUE_URL,
MessageBody: JSON.stringify({
jobId,
userId: request.userId,
urls: request.urls,
}),
MessageAttributes: {
jobType: {
DataType: 'String',
StringValue: 'BULK_CREATE'
}
}
}));
return {
statusCode: 202,
body: JSON.stringify({
jobId,
message: 'Bulk creation job queued',
estimatedCompletionTime: Math.ceil(request.urls.length / 10) + ' minutes'
}),
};
}
// Process small batches synchronously
const results = await Promise.allSettled(
request.urls.map(async (urlData) => {
const shortCode = urlData.customSlug || nanoid(8);
// Validate URL before creating
if (!isValidUrl(urlData.originalUrl)) {
throw new Error(`Invalid URL: ${urlData.originalUrl}`);
}
// Check for malicious content (more on this later)
await validateUrlSafety(urlData.originalUrl);
return await createShortUrl({
shortCode,
originalUrl: urlData.originalUrl,
userId: request.userId,
expiresAt: urlData.expiresAt,
tags: urlData.tags || [],
});
})
);
const successful = results
.filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<any>).value);
const failed = results
.filter(result => result.status === 'rejected')
.map(result => (result as PromiseRejectedResult).reason.message);
return {
statusCode: 200,
body: JSON.stringify({
successful: successful.length,
failed: failed.length,
errors: failed,
urls: successful,
}),
};
} catch (error) {
console.error('Bulk create error:', error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' }),
};
}
}
function isValidUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
return ['http:', 'https:'].includes(parsedUrl.protocol);
} catch {
return false;
}
}
async function validateUrlSafety(url: string): Promise<void> {
// Implementation coming up in security section
// This is where we check against malicious domains
}
URL Expiration und Scheduling: Time-Based Features#
Marketing Kampagnen brauchen Expiration Dates. So implementierst du URL Expiration ohne teure Cleanup Jobs zu fahren:
// lambda/redirect-with-expiration.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const shortCode = event.pathParameters?.shortCode;
if (!shortCode) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'Short code not found' }),
};
}
try {
const response = await dynamodb.send(new GetItemCommand({
TableName: process.env.URLS_TABLE_NAME,
Key: marshall({ shortCode }),
}));
if (!response.Item) {
return {
statusCode: 404,
headers: {
'Content-Type': 'text/html',
},
body: createNotFoundPage(),
};
}
const item = unmarshall(response.Item);
// Check expiration
if (item.expiresAt && new Date(item.expiresAt) < new Date()) {
// URL expired - optionally log this for analytics
await recordExpiredAccess(shortCode, item.userId);
return {
statusCode: 410, // Gone
headers: {
'Content-Type': 'text/html',
},
body: createExpiredPage(item.originalUrl),
};
}
// Check if URL is scheduled for future activation
if (item.activateAt && new Date(item.activateAt) > new Date()) {
return {
statusCode: 404, // Not yet active
headers: {
'Content-Type': 'text/html',
},
body: createNotYetActivePage(),
};
}
// Update click count asynchronously (fire and forget)
updateClickCount(shortCode, event).catch(console.error);
return {
statusCode: 302,
headers: {
Location: item.originalUrl,
'Cache-Control': 'no-cache', // Important for expired URLs
},
body: '',
};
} catch (error) {
console.error('Redirect error:', error);
return {
statusCode: 500,
headers: {
'Content-Type': 'text/html',
},
body: createErrorPage(),
};
}
}
async function recordExpiredAccess(shortCode: string, userId: string): Promise<void> {
// Record that someone tried to access an expired URL
// Useful for analytics and potential abuse detection
try {
await dynamodb.send(new UpdateItemCommand({
TableName: process.env.ANALYTICS_TABLE_NAME,
Key: marshall({
pk: `USER#${userId}`,
sk: `EXPIRED#${shortCode}#${Date.now()}`,
}),
UpdateExpression: 'SET #count = if_not_exists(#count, :zero) + :inc',
ExpressionAttributeNames: {
'#count': 'expiredAccessCount',
},
ExpressionAttributeValues: marshall({
':zero': 0,
':inc': 1,
}),
}));
} catch (error) {
console.error('Failed to record expired access:', error);
}
}
function createExpiredPage(originalUrl: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<title>Link Abgelaufen</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 100px; }
.container { max-width: 500px; margin: 0 auto; }
</style>
</head>
<body>
<div class="container">
<h1>Link Abgelaufen</h1>
<p>Dieser Link ist abgelaufen und nicht mehr verfügbar.</p>
<p>Original Destination: <code>${originalUrl}</code></p>
<a href="/">Zur Startseite</a>
</div>
</body>
</html>
`;
}
Security: Defense in Depth#
Jetzt zum Kernstück dieses Posts. Security ist kein Nachgedanke—es ist das, was deinen Service davon abhält, eine Malware Distribution Platform zu werden. Hier ist unser layered Security Approach:
Layer 1: Input Validation und URL Safety#
// lambda/url-validator.ts
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
// Malicious domain blacklist (this should be regularly updated)
const MALICIOUS_DOMAINS = new Set([
// Add known malicious domains here
// In production, load this from DynamoDB or Parameter Store
]);
// URL patterns that are commonly abused
const SUSPICIOUS_PATTERNS = [
/bit\.ly/i, // Nested shorteners
/tinyurl\.com/i, // Nested shorteners
/localhost/i, // Local development
/192\.168\./i, // Private networks
/127\.0\.0\.1/i, // Localhost
/10\./i, // Private networks
/172\.16\./i, // Private networks
];
export async function validateUrlSafety(url: string): Promise<{
isValid: boolean;
reason?: string;
}> {
try {
const parsedUrl = new URL(url);
// Check protocol
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return {
isValid: false,
reason: 'Nur HTTP und HTTPS URLs sind erlaubt'
};
}
// Check for private/local addresses
if (SUSPICIOUS_PATTERNS.some(pattern => pattern.test(url))) {
return {
isValid: false,
reason: 'URL enthält verdächtige Patterns'
};
}
// Check against malicious domain blacklist
if (MALICIOUS_DOMAINS.has(parsedUrl.hostname.toLowerCase())) {
return {
isValid: false,
reason: 'Domain ist auf der Blacklist'
};
}
// Check against dynamic blacklist in DynamoDB
const blacklistCheck = await dynamodb.send(new GetItemCommand({
TableName: process.env.BLACKLIST_TABLE_NAME,
Key: marshall({
domain: parsedUrl.hostname.toLowerCase()
}),
}));
if (blacklistCheck.Item) {
return {
isValid: false,
reason: 'Domain ist auf der Blacklist'
};
}
// Optional: Check against external reputation services
const reputationCheck = await checkUrlReputation(url);
if (!reputationCheck.isValid) {
return reputationCheck;
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
reason: 'Ungültiges URL Format'
};
}
}
async function checkUrlReputation(url: string): Promise<{
isValid: boolean;
reason?: string;
}> {
// In production, integrate with services like:
// - Google Safe Browsing API
// - VirusTotal API
// - URLVoid API
// For now, return valid
return { isValid: true };
}
Layer 2: Authentifizierung und Autorisierung#
// lambda/authorizer.ts
import { APIGatewayTokenAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda';
import { verify } from 'jsonwebtoken';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
interface JWTPayload {
sub: string;
email: string;
role: string;
exp: number;
}
export async function handler(
event: APIGatewayTokenAuthorizerEvent
): Promise<APIGatewayAuthorizerResult> {
try {
const token = event.authorizationToken?.replace('Bearer ', '');
if (!token) {
throw new Error('Kein Token bereitgestellt');
}
// Verify JWT token
const decoded = verify(token, process.env.JWT_SECRET!) as JWTPayload;
// Get user details from DynamoDB
const userResponse = await dynamodb.send(new GetItemCommand({
TableName: process.env.USERS_TABLE_NAME,
Key: marshall({ userId: decoded.sub }),
}));
if (!userResponse.Item) {
throw new Error('Benutzer nicht gefunden');
}
const user = unmarshall(userResponse.Item);
// Check if user is active
if (user.status !== 'ACTIVE') {
throw new Error('Benutzer ist nicht aktiv');
}
// Generate policy based on user role
const policy = generatePolicy(decoded.sub, 'Allow', event.methodArn, user.role);
// Add user context to be available in Lambda functions
policy.context = {
userId: decoded.sub,
email: decoded.email,
role: user.role,
planType: user.planType || 'free',
};
return policy;
} catch (error) {
console.error('Autorisierung fehlgeschlagen:', error);
throw new Error('Unauthorized');
}
}
function generatePolicy(
principalId: string,
effect: 'Allow' | 'Deny',
resource: string,
role: string
): APIGatewayAuthorizerResult {
const policyDocument = {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resource,
},
],
};
// Role-based permissions
if (role === 'admin') {
// Admins can access all endpoints
policyDocument.Statement[0].Resource = '*';
} else if (role === 'premium') {
// Premium users get access to advanced features
policyDocument.Statement.push({
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: resource.replace('/create', '/bulk-create'),
});
}
return {
principalId,
policyDocument,
};
}
Layer 3: Rate Limiting und Abuse Protection#
// lambda/rate-limiter.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, UpdateItemCommand, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
const dynamodb = new DynamoDBClient({});
interface RateLimitConfig {
requestsPerMinute: number;
requestsPerHour: number;
requestsPerDay: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
free: {
requestsPerMinute: 10,
requestsPerHour: 100,
requestsPerDay: 1000,
},
premium: {
requestsPerMinute: 100,
requestsPerHour: 1000,
requestsPerDay: 10000,
},
admin: {
requestsPerMinute: 1000,
requestsPerHour: 10000,
requestsPerDay: 100000,
},
};
export async function checkRateLimit(
userId: string,
planType: string = 'free',
clientIp?: string
): Promise<{
allowed: boolean;
resetTime?: number;
remainingRequests?: number;
}> {
const config = RATE_LIMITS[planType] || RATE_LIMITS.free;
const now = Date.now();
// Create time windows
const minuteWindow = Math.floor(now / (60 * 1000));
const hourWindow = Math.floor(now / (60 * 60 * 1000));
const dayWindow = Math.floor(now / (24 * 60 * 60 * 1000));
try {
// Check and update rate limits atomically
const updateResult = await dynamodb.send(new UpdateItemCommand({
TableName: process.env.RATE_LIMIT_TABLE_NAME,
Key: marshall({
userId,
window: 'COMBINED'
}),
UpdateExpression: `
SET
#minute = if_not_exists(#minute, :zero),
#hour = if_not_exists(#hour, :zero),
#day = if_not_exists(#day, :zero),
#minuteWindow = if_not_exists(#minuteWindow, :currentMinute),
#hourWindow = if_not_exists(#hourWindow, :currentHour),
#dayWindow = if_not_exists(#dayWindow, :currentDay)
ADD
#minute :inc,
#hour :inc,
#day :inc
`,
ConditionExpression: `
(attribute_not_exists(#minuteWindow) OR #minuteWindow = :currentMinute OR #minute < :minuteLimit) AND
(attribute_not_exists(#hourWindow) OR #hourWindow = :currentHour OR #hour < :hourLimit) AND
(attribute_not_exists(#dayWindow) OR #dayWindow = :currentDay OR #day < :dayLimit)
`,
ExpressionAttributeNames: {
'#minute': 'requestsThisMinute',
'#hour': 'requestsThisHour',
'#day': 'requestsThisDay',
'#minuteWindow': 'minuteWindow',
'#hourWindow': 'hourWindow',
'#dayWindow': 'dayWindow',
},
ExpressionAttributeValues: marshall({
':zero': 0,
':inc': 1,
':currentMinute': minuteWindow,
':currentHour': hourWindow,
':currentDay': dayWindow,
':minuteLimit': config.requestsPerMinute,
':hourLimit': config.requestsPerHour,
':dayLimit': config.requestsPerDay,
}),
ReturnValues: 'ALL_NEW',
}));
const item = unmarshall(updateResult.Attributes!);
return {
allowed: true,
remainingRequests: Math.min(
config.requestsPerMinute - item.requestsThisMinute,
config.requestsPerHour - item.requestsThisHour,
config.requestsPerDay - item.requestsThisDay
),
};
} catch (error: any) {
if (error.name === 'ConditionalCheckFailedException') {
// Rate limit exceeded
const getResult = await dynamodb.send(new GetItemCommand({
TableName: process.env.RATE_LIMIT_TABLE_NAME,
Key: marshall({ userId, window: 'COMBINED' }),
}));
if (getResult.Item) {
const item = unmarshall(getResult.Item);
// Calculate reset time based on which limit was hit
let resetTime = now + (60 * 1000); // Default to 1 minute
if (item.requestsThisDay >= config.requestsPerDay) {
resetTime = (dayWindow + 1) * 24 * 60 * 60 * 1000;
} else if (item.requestsThisHour >= config.requestsPerHour) {
resetTime = (hourWindow + 1) * 60 * 60 * 1000;
}
return {
allowed: false,
resetTime,
remainingRequests: 0,
};
}
}
throw error;
}
}
export function createRateLimitResponse(resetTime: number): APIGatewayProxyResult {
return {
statusCode: 429,
headers: {
'X-RateLimit-Reset': Math.ceil(resetTime / 1000).toString(),
'Retry-After': Math.ceil((resetTime - Date.now()) / 1000).toString(),
},
body: JSON.stringify({
error: 'Rate Limit überschritten',
message: 'Zu viele Requests. Bitte versuche es später nochmal.',
resetTime: new Date(resetTime).toISOString(),
}),
};
}
Layer 4: AWS WAF Protection#
// lib/waf-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as wafv2 from 'aws-cdk-lib/aws-wafv2';
import * as logs from 'aws-cdk-lib/aws-logs';
export class WAFStack extends Stack {
public readonly webAcl: wafv2.CfnWebACL;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
// Create CloudWatch log group for WAF logs
const logGroup = new logs.LogGroup(this, 'WAFLogGroup', {
logGroupName: `/aws/wafv2/link-shortener`,
retention: logs.RetentionDays.ONE_MONTH,
});
this.webAcl = new wafv2.CfnWebACL(this, 'LinkShortenerWAF', {
scope: 'CLOUDFRONT', // Use REGIONAL for ALB/API Gateway
defaultAction: { allow: {} },
rules: [
// Rule 1: AWS Managed Rules - Core Rule Set
{
name: 'AWSManagedRulesCore',
priority: 1,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesCommonRuleSet',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesCore',
},
},
// Rule 2: Rate limiting for URL creation
{
name: 'RateLimitCreation',
priority: 2,
statement: {
rateBasedStatement: {
limit: 1000, // requests per 5-minute window
aggregateKeyType: 'IP',
scopeDownStatement: {
byteMatchStatement: {
searchString: '/create',
fieldToMatch: { uriPath: {} },
textTransformations: [
{ priority: 0, type: 'LOWERCASE' },
],
positionalConstraint: 'CONTAINS',
},
},
},
},
action: {
block: {
customResponse: {
responseCode: 429,
customResponseBodyKey: 'RateLimitExceeded',
},
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'RateLimitCreation',
},
},
// Rule 3: Block known bot networks
{
name: 'AWSManagedRulesBot',
priority: 3,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesBotControlRuleSet',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesBot',
},
},
// Rule 4: IP reputation list
{
name: 'AWSManagedRulesIPReputation',
priority: 4,
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesAmazonIpReputationList',
},
},
overrideAction: { none: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'AWSManagedRulesIPReputation',
},
},
// Rule 5: Custom geo-blocking (if needed)
{
name: 'GeoBlocking',
priority: 5,
statement: {
geoMatchStatement: {
// Block requests from specific countries if needed
countryCodes: [], // Add country codes to block
},
},
action: { block: {} },
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'GeoBlocking',
},
},
],
customResponseBodies: {
RateLimitExceeded: {
contentType: 'APPLICATION_JSON',
content: JSON.stringify({
error: 'Rate Limit überschritten',
message: 'Zu viele Requests von deiner IP Adresse. Bitte versuche es später nochmal.',
}),
},
},
visibilityConfig: {
sampledRequestsEnabled: true,
cloudWatchMetricsEnabled: true,
metricName: 'LinkShortenerWAF',
},
});
// Enable logging
new wafv2.CfnLoggingConfiguration(this, 'WAFLogging', {
resourceArn: this.webAcl.attrArn,
logDestinationConfigs: [logGroup.logGroupArn],
loggingFilter: {
defaultBehavior: 'KEEP',
filters: [
{
behavior: 'DROP',
conditions: [
{
actionCondition: {
action: 'ALLOW',
},
},
],
requirement: 'MEETS_ANY',
},
],
},
});
}
}
Advanced Analytics und Monitoring#
Security ist nicht nur darum, Bad Actors zu blocken—es geht darum zu verstehen, was in deinem System passiert:
// lambda/security-monitor.ts
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
const cloudwatch = new CloudWatchClient({});
const sns = new SNSClient({});
export interface SecurityEvent {
type: 'RATE_LIMIT_EXCEEDED' | 'MALICIOUS_URL_BLOCKED' | 'SUSPICIOUS_BULK_REQUEST';
userId?: string;
clientIp: string;
userAgent?: string;
details: Record<string, any>;
timestamp: number;
}
export async function recordSecurityEvent(event: SecurityEvent): Promise<void> {
try {
// Send metric to CloudWatch
await cloudwatch.send(new PutMetricDataCommand({
Namespace: 'LinkShortener/Security',
MetricData: [
{
MetricName: event.type,
Value: 1,
Unit: 'Count',
Timestamp: new Date(event.timestamp),
Dimensions: [
{
Name: 'EventType',
Value: event.type,
},
...(event.userId ? [{
Name: 'UserId',
Value: event.userId,
}] : []),
],
},
],
}));
// For critical events, send SNS alert
if (shouldAlertOn(event)) {
await sns.send(new PublishCommand({
TopicArn: process.env.SECURITY_ALERTS_TOPIC_ARN,
Subject: `Security Alert: ${event.type}`,
Message: JSON.stringify({
eventType: event.type,
timestamp: new Date(event.timestamp).toISOString(),
clientIp: event.clientIp,
userId: event.userId,
details: event.details,
}, null, 2),
}));
}
console.log('Security event recorded:', {
type: event.type,
userId: event.userId,
clientIp: event.clientIp,
timestamp: event.timestamp,
});
} catch (error) {
console.error('Failed to record security event:', error);
// Don't throw - security monitoring failures shouldn't break the main flow
}
}
function shouldAlertOn(event: SecurityEvent): boolean {
// Define which events should trigger immediate alerts
const alertEvents: SecurityEvent['type'][] = [
'MALICIOUS_URL_BLOCKED',
'SUSPICIOUS_BULK_REQUEST',
];
return alertEvents.includes(event.type);
}
// Create a dashboard for security metrics
export async function createSecurityDashboard(): Promise<void> {
// This would be part of your CDK infrastructure code
// Implementation depends on your specific monitoring needs
}
Alles zusammenbringen: Die Security-First API#
So kommen alle diese Security Layer in einem Production Endpoint zusammen:
// lambda/secure-create-url.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { validateUrlSafety } from './url-validator';
import { checkRateLimit, createRateLimitResponse } from './rate-limiter';
import { recordSecurityEvent } from './security-monitor';
import { nanoid } from 'nanoid';
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
const startTime = Date.now();
try {
// Extract user context from authorizer
const userId = event.requestContext.authorizer?.userId;
const planType = event.requestContext.authorizer?.planType || 'free';
const clientIp = event.requestContext.identity?.sourceIp;
if (!userId) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Authentifizierung erforderlich' }),
};
}
// Check rate limits first (fail fast)
const rateLimitCheck = await checkRateLimit(userId, planType, clientIp);
if (!rateLimitCheck.allowed) {
await recordSecurityEvent({
type: 'RATE_LIMIT_EXCEEDED',
userId,
clientIp: clientIp!,
userAgent: event.headers['User-Agent'],
details: { planType, resetTime: rateLimitCheck.resetTime },
timestamp: Date.now(),
});
return createRateLimitResponse(rateLimitCheck.resetTime!);
}
// Parse and validate request
const request = JSON.parse(event.body || '{}');
if (!request.originalUrl) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'originalUrl ist erforderlich',
remainingRequests: rateLimitCheck.remainingRequests
}),
};
}
// Validate URL safety
const safetyCheck = await validateUrlSafety(request.originalUrl);
if (!safetyCheck.isValid) {
await recordSecurityEvent({
type: 'MALICIOUS_URL_BLOCKED',
userId,
clientIp: clientIp!,
userAgent: event.headers['User-Agent'],
details: {
originalUrl: request.originalUrl,
reason: safetyCheck.reason
},
timestamp: Date.now(),
});
return {
statusCode: 400,
body: JSON.stringify({
error: 'URL Validierung fehlgeschlagen',
reason: safetyCheck.reason,
remainingRequests: rateLimitCheck.remainingRequests
}),
};
}
// Create short URL
const shortCode = request.customSlug || nanoid(8);
// TODO: Save to DynamoDB (implementation from previous parts)
const shortUrl = await createShortUrl({
shortCode,
originalUrl: request.originalUrl,
userId,
expiresAt: request.expiresAt,
tags: request.tags || [],
});
const responseTime = Date.now() - startTime;
// Record successful creation
console.log(`URL created successfully: ${shortCode} -> ${request.originalUrl} (${responseTime}ms)`);
return {
statusCode: 201,
headers: {
'X-RateLimit-Remaining': rateLimitCheck.remainingRequests?.toString() || '0',
'X-Response-Time': responseTime.toString(),
},
body: JSON.stringify({
shortCode,
shortUrl: `${process.env.DOMAIN_NAME}/${shortCode}`,
originalUrl: request.originalUrl,
createdAt: new Date().toISOString(),
expiresAt: request.expiresAt,
remainingRequests: rateLimitCheck.remainingRequests,
}),
};
} catch (error) {
console.error('Error creating short URL:', error);
const responseTime = Date.now() - startTime;
return {
statusCode: 500,
headers: {
'X-Response-Time': responseTime.toString(),
},
body: JSON.stringify({
error: 'Internal server error',
requestId: event.requestContext.requestId
}),
};
}
}
Lessons Learned: Was ich gern früher gewusst hätte#
Nach dem Betreiben in Production für zwei Jahre, hier sind die hart erarbeiteten Einsichten:
1. Fang mit Security an, nicht mit Performance Wir haben ursprünglich darauf fokussiert, Redirects schnell zu machen, dann Security später draufgesetzt. Großer Fehler. Mit Security-first Thinking neu aufzubauen hätte uns wochenlange Refactoring und mehrere peinliche Vorfälle erspart.
2. Rate Limiting ist schwieriger als es aussieht Einfache Token Bucket Algorithmen funktionieren nicht gut mit Serverless, weil du den State zwischen Invocations verlierst. DynamoDB atomic Counters mit Time Windows funktionieren besser, aber pass auf deine Write Capacity Units auf.
3. URL Validation ist nie komplett Egal wie umfassend deine Malicious URL Detection ist, Angreifer werden neue Domains finden. Bau ein System, das schnell geupdatet werden kann, anstatt zu versuchen vom ersten Tag an perfekt zu sein.
4. Monitor alles, alert auf Patterns Einzelne Security Events sind meist nicht interessant. Patterns sind was zählt. Bau dein Monitoring so auf, dass es Trends erkennt: gleiche IP erstellt viele URLs, ungewöhnliche Redirect Patterns, Bulk Operations von neuen Accounts.
5. Custom Domains sind die Komplexität wert Marketing Teams werden irgendwann nach branded Short URLs fragen. Custom Domain Support früh zu bauen ist einfacher als es später nachzurüsten. Der SSL Certificate Dance ist nervig aber managebar.
Was kommt als Nächstes?#
In Teil 4 werden wir Production Deployment Strategien, Monitoring das tatsächlich beim Debuggen von Issues hilft, und Cost Optimization Techniken behandeln, die dir hunderte Dollars pro Monat sparen können.
Wir tauchen auch in die operationellen Aspekte ein: wie du Traffic Spikes handhabst, Database Scaling Patterns, und das Monitoring Setup, das dir hilft ruhig zu schlafen, wissend dass dein Link Shortener nicht der nächste Security Incident in deinem Incident Log wird.
Die Security Foundation, die wir hier aufgebaut haben, wird uns gut dienen, wenn wir skalieren um Millionen von Redirects pro Tag zu handhaben. Aber zuerst müssen wir sicherstellen, dass unsere Deployment Pipeline und unser Monitoring mithalten können.
Hast du War Stories über Link Shortener Security Incidents? Ich würde sie gern hören. Die Kreativität der Angreifer hört nie auf mich zu verblüffen, und das Teilen dieser Stories hilft uns allen bessere Defenses aufzubauen.
AWS CDK Link-Verkürzer: Von Null auf Produktion
Eine umfassende 5-teilige Serie über den Aufbau eines produktionsreifen Link-Verkürzungsdienstes mit AWS CDK, Node.js Lambda und DynamoDB. Mit echten Produktionsgeschichten, Performance-Optimierung und Kostenmanagement.
Alle Beiträge in dieser Serie
Kommentare (0)
An der Unterhaltung teilnehmen
Melde dich an, um deine Gedanken zu teilen und mit der Community zu interagieren
Noch keine Kommentare
Sei der erste, der deine Gedanken zu diesem Beitrag teilt!
Kommentare (0)
An der Unterhaltung teilnehmen
Melde dich an, um deine Gedanken zu teilen und mit der Community zu interagieren
Noch keine Kommentare
Sei der erste, der deine Gedanken zu diesem Beitrag teilt!